function getMyMonthClosure(token, period) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } period = String( period || Utilities.formatDate( new Date(), CFG.TZ, 'yyyy-MM' ) ).trim(); if ( !/^\d{4}-\d{2}$/.test( period ) ) { throw new Error( 'Neplatné období.' ); } /* * Načteme uzávěrky jako TEXT. * Žádné Date objekty posílané do browseru. */ let closures = displayRows_( CFG.SHEETS.MONTH_CLOSURES ); let closure = closures.find( function(row) { return ( String( row.employee_id ) === String( user.employee_id ) && String( row.period ) === period ); } ); /* * Pokud ještě uzávěrka neexistuje, * automaticky ji založíme. */ if ( !closure ) { const closureId = uuid_( 'CLOSE' ); append_( CFG.SHEETS.MONTH_CLOSURES, { closure_id: closureId, employee_id: user.employee_id, period: period, employee_confirmed_at: '', manager_approved_at: '', manager_approved_by: '', locked_at: '', status: 'WAITING_EMPLOYEE' } ); /* * Znovu načteme už jako text. */ closures = displayRows_( CFG.SHEETS.MONTH_CLOSURES ); closure = closures.find( function(row) { return ( String( row.closure_id ) === String( closureId ) ); } ); } if ( !closure ) { throw new Error( 'Nepodařilo se vytvořit měsíční uzávěrku.' ); } const summary = getClosureSummary_( user.employee_id, period ); /* * Do browseru pouze primitivní typy. */ return { closure_id: String( closure.closure_id || '' ), employee_id: String( closure.employee_id || '' ), period: String( closure.period || period ), status: String( closure.status || 'WAITING_EMPLOYEE' ), employee_confirmed_at: String( closure.employee_confirmed_at || '' ), manager_approved_at: String( closure.manager_approved_at || '' ), locked_at: String( closure.locked_at || '' ), summary: { worked_minutes: Number( summary.worked_minutes || 0 ), shift_count: Number( summary.shift_count || 0 ), earned_estimate: Number( summary.earned_estimate || 0 ), night_minutes: Number( summary.night_minutes || 0 ), weekend_minutes: Number( summary.weekend_minutes || 0 ) } }; } /* ============================================================ POTVRZENÍ DOCHÁZKY ZAMĚSTNANCEM ============================================================ */ function confirmMyMonthClosure( token, period ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } period = String( period || '' ).trim(); if ( !/^\d{4}-\d{2}$/.test( period ) ) { throw new Error( 'Neplatné období.' ); } const lock = LockService .getScriptLock(); lock.waitLock( 10000 ); try { /* * Nejdřív si uzávěrku vyžádáme. * Tím zajistíme, že existuje. */ const closureData = getMyMonthClosure( token, period ); const closureId = closureData.closure_id; const currentStatus = String( closureData.status || '' ); if ( currentStatus === 'LOCKED' ) { throw new Error( 'Docházka je už uzamčena.' ); } if ( currentStatus === 'MANAGER_APPROVED' ) { throw new Error( 'Docházka už byla schválena vedoucím.' ); } if ( currentStatus === 'EMPLOYEE_CONFIRMED' ) { return { ok:true }; } /* * Kontrola otevřené směny v daném měsíci. */ const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const openShift = shifts.find( function(shift) { if ( String( shift.employee_id ) !== String( user.employee_id ) ) { return false; } if ( String( shift.status ) !== 'OPEN' ) { return false; } const date = parseEmployeeDate_( shift.clock_in ); if ( !date ) { return false; } return ( Utilities.formatDate( date, CFG.TZ, 'yyyy-MM' ) === period ); } ); if ( openShift ) { throw new Error( 'V tomto měsíci máš stále otevřenou směnu.' ); } /* * Nevyřešené žádosti o opravu. */ const requests = displayRows_( CFG.SHEETS.SHIFT_CHANGE_REQUESTS ); const pendingRequest = requests.find( function(request) { return ( String( request.employee_id ) === String( user.employee_id ) && String( request.status ) === 'PENDING' ); } ); if ( pendingRequest ) { throw new Error( 'Nejdřív je potřeba vyřešit žádost o opravu docházky.' ); } updateBy_( CFG.SHEETS.MONTH_CLOSURES, 'closure_id', closureId, { employee_confirmed_at: now_(), status: 'EMPLOYEE_CONFIRMED' } ); setMonthShiftStatus_( user.employee_id, period, 'EMPLOYEE_CONFIRMED' ); audit_( user.user_id, 'MONTH_CONFIRMED', 'MONTH_CLOSURE', closureId, currentStatus, 'EMPLOYEE_CONFIRMED' ); notifyManagers_( user.employee_id, 'Docházka čeká na schválení', 'Zaměstnanec potvrdil docházku za ' + period + '.' ); return { ok:true }; } finally { lock.releaseLock(); } } /* ============================================================ SOUHRN MĚSÍCE ============================================================ */ function getClosureSummary_( employeeId, period ) { const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const rates = displayRows_( CFG.SHEETS.PAY_RATES ); let workedMinutes = 0; let shiftCount = 0; let earnedEstimate = 0; let nightMinutes = 0; let weekendMinutes = 0; shifts.forEach( function(shift) { if ( String( shift.employee_id ) !== String( employeeId ) ) { return; } const date = parseEmployeeDate_( shift.clock_in ); if ( !date ) { return; } const shiftPeriod = Utilities.formatDate( date, CFG.TZ, 'yyyy-MM' ); if ( shiftPeriod !== period ) { return; } /* * Otevřenou směnu nepočítáme. */ if ( String( shift.status ) === 'OPEN' ) { return; } const minutes = numberFromSheet_( shift.worked_minutes ); const night = numberFromSheet_( shift.night_minutes ); const weekend = numberFromSheet_( shift.weekend_minutes ); workedMinutes += minutes; nightMinutes += night; weekendMinutes += weekend; if ( minutes > 0 ) { shiftCount++; } const hourlyRate = employeeRateAt_( rates, employeeId, date ); earnedEstimate += ( minutes / 60 ) * hourlyRate; } ); return { worked_minutes: Math.round( workedMinutes ), shift_count: shiftCount, earned_estimate: Math.round( earnedEstimate ), night_minutes: Math.round( nightMinutes ), weekend_minutes: Math.round( weekendMinutes ) }; } /* ============================================================ ŽÁDOST O OPRAVU SMĚNY ============================================================ */ function createAttendanceCorrectionRequest( token, shiftId, requestedClockIn, requestedClockOut, reason ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const shift = shifts.find( function(row) { return ( String( row.shift_id ) === String( shiftId ) && String( row.employee_id ) === String( user.employee_id ) ); } ); if ( !shift ) { throw new Error( 'Směna nebyla nalezena.' ); } if ( String( shift.status ) === 'LOCKED' ) { throw new Error( 'Uzamčenou směnu už nelze upravit.' ); } reason = String( reason || '' ).trim(); if ( reason.length < 3 ) { throw new Error( 'Doplň důvod opravy.' ); } const requestId = uuid_( 'FIX' ); append_( CFG.SHEETS.SHIFT_CHANGE_REQUESTS, { request_id: requestId, shift_id: shift.shift_id, employee_id: user.employee_id, field: 'SHIFT_TIME', old_value: JSON.stringify({ clock_in: shift.clock_in, clock_out: shift.clock_out }), requested_value: JSON.stringify({ clock_in: requestedClockIn || '', clock_out: requestedClockOut || '' }), reason: reason, status: 'PENDING', created_at: now_(), resolved_at: '', resolved_by: '' } ); audit_( user.user_id, 'ATTENDANCE_CORRECTION_REQUESTED', 'SHIFT', shift.shift_id, '', requestId ); notifyManagers_( user.employee_id, 'Nová žádost o opravu docházky', 'Zaměstnanec požádal o opravu směny.' ); return { ok:true }; } /* ============================================================ ADMIN – ČEKAJÍCÍ UZÁVĚRKY ============================================================ */ function getPendingClosures(token) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const closures = displayRows_( CFG.SHEETS.MONTH_CLOSURES ); const employees = displayRows_( CFG.SHEETS.EMPLOYEES ); const employeeMap = {}; employees.forEach( function(employee) { employeeMap[ String( employee.employee_id ) ] = employee; } ); return closures .filter( function(closure) { return [ 'EMPLOYEE_CONFIRMED', 'MANAGER_APPROVED' ].includes( String( closure.status ) ); } ) .map( function(closure) { const employee = employeeMap[ String( closure.employee_id ) ]; const summary = getClosureSummary_( closure.employee_id, closure.period ); return { closure_id: String( closure.closure_id || '' ), employee_id: String( closure.employee_id || '' ), employee_name: employee ? ( String( employee.first_name || '' ) + ' ' + String( employee.last_name || '' ) ).trim() : String( closure.employee_id || '' ), period: String( closure.period || '' ), status: String( closure.status || '' ), worked_minutes: Number( summary.worked_minutes || 0 ), shift_count: Number( summary.shift_count || 0 ), earned_estimate: Number( summary.earned_estimate || 0 ) }; } ); } /* ============================================================ ADMIN / MANAGER – SCHVÁLENÍ ============================================================ */ function managerApproveClosure( token, closureId ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const closures = displayRows_( CFG.SHEETS.MONTH_CLOSURES ); const closure = closures.find( function(row) { return ( String( row.closure_id ) === String( closureId ) ); } ); if ( !closure ) { throw new Error( 'Uzávěrka nebyla nalezena.' ); } if ( String( closure.status ) !== 'EMPLOYEE_CONFIRMED' ) { throw new Error( 'Docházku zatím nepotvrdil zaměstnanec.' ); } const requests = displayRows_( CFG.SHEETS.SHIFT_CHANGE_REQUESTS ); const pending = requests.find( function(request) { return ( String( request.employee_id ) === String( closure.employee_id ) && String( request.status ) === 'PENDING' ); } ); if ( pending ) { throw new Error( 'Zaměstnanec má nevyřešenou žádost o opravu docházky.' ); } updateBy_( CFG.SHEETS.MONTH_CLOSURES, 'closure_id', closureId, { manager_approved_at: now_(), manager_approved_by: user.user_id, status: 'MANAGER_APPROVED' } ); setMonthShiftStatus_( closure.employee_id, closure.period, 'MANAGER_APPROVED' ); audit_( user.user_id, 'MONTH_APPROVED', 'MONTH_CLOSURE', closureId, 'EMPLOYEE_CONFIRMED', 'MANAGER_APPROVED' ); notifyEmployee_( closure.employee_id, 'Docházka schválena', 'Docházka za ' + closure.period + ' byla schválena.' ); return { ok:true }; } /* ============================================================ ADMIN – UZAMČENÍ ============================================================ */ function lockMonthClosure( token, closureId ) { const user = requireUser_( token, [ 'ADMIN' ] ); const closures = displayRows_( CFG.SHEETS.MONTH_CLOSURES ); const closure = closures.find( function(row) { return ( String( row.closure_id ) === String( closureId ) ); } ); if ( !closure ) { throw new Error( 'Uzávěrka nebyla nalezena.' ); } if ( String( closure.status ) !== 'MANAGER_APPROVED' ) { throw new Error( 'Uzávěrku lze zamknout až po schválení.' ); } updateBy_( CFG.SHEETS.MONTH_CLOSURES, 'closure_id', closureId, { locked_at: now_(), status: 'LOCKED' } ); setMonthShiftStatus_( closure.employee_id, closure.period, 'LOCKED' ); createPayrollDraft_( closure.employee_id, closure.period ); audit_( user.user_id, 'MONTH_LOCKED', 'MONTH_CLOSURE', closureId, 'MANAGER_APPROVED', 'LOCKED' ); notifyEmployee_( closure.employee_id, 'Docházka uzavřena', 'Docházka za ' + closure.period + ' byla uzavřena a předána ke zpracování.' ); return { ok:true }; } /* ============================================================ PŘEPIS STAVU SMĚN V MĚSÍCI ============================================================ */ function setMonthShiftStatus_( employeeId, period, newStatus ) { const sheet = sh_( CFG.SHEETS.SHIFTS ); const range = sheet.getDataRange(); const values = range.getValues(); if ( values.length < 2 ) { return; } const headers = values[0] .map( function(value) { return String( value ); } ); const employeeIndex = headers.indexOf( 'employee_id' ); const clockInIndex = headers.indexOf( 'clock_in' ); const statusIndex = headers.indexOf( 'status' ); const updatedIndex = headers.indexOf( 'updated_at' ); if ( employeeIndex < 0 || clockInIndex < 0 || statusIndex < 0 ) { throw new Error( 'Tabulka SHIFTS nemá potřebné sloupce.' ); } for ( let i = 1; i < values.length; i++ ) { const row = values[i]; if ( String( row[ employeeIndex ] ) !== String( employeeId ) ) { continue; } const date = parseEmployeeDate_( row[ clockInIndex ] ); if ( !date ) { continue; } const rowPeriod = Utilities.formatDate( date, CFG.TZ, 'yyyy-MM' ); if ( rowPeriod !== period ) { continue; } /* * Otevřenou směnu nesmíme * přepsat na potvrzenou. */ if ( String( row[ statusIndex ] ) === 'OPEN' ) { continue; } sheet .getRange( i + 1, statusIndex + 1 ) .setValue( newStatus ); if ( updatedIndex >= 0 ) { sheet .getRange( i + 1, updatedIndex + 1 ) .setValue( now_() ); } } } /* ============================================================ PAYROLL DRAFT ============================================================ */ function createPayrollDraft_( employeeId, period ) { const payrolls = displayRows_( CFG.SHEETS.PAYROLL ); const existing = payrolls.find( function(row) { return ( String( row.employee_id ) === String( employeeId ) && String( row.period ) === String( period ) ); } ); const summary = getClosureSummary_( employeeId, period ); if ( existing ) { updateBy_( CFG.SHEETS.PAYROLL, 'payroll_id', existing.payroll_id, { approved_minutes: summary.worked_minutes, base_amount: summary.earned_estimate, final_amount: summary.earned_estimate, status: 'READY_FOR_ACCOUNTANT', updated_at: now_() } ); return; } append_( CFG.SHEETS.PAYROLL, { payroll_id: uuid_( 'PAY' ), employee_id: employeeId, period: period, approved_minutes: summary.worked_minutes, base_amount: summary.earned_estimate, bonus_amount: 0, tips_amount: 0, other_amount: 0, final_amount: summary.earned_estimate, status: 'READY_FOR_ACCOUNTANT', payment_date: '', updated_at: now_() } ); } /* ============================================================ NOTIFIKACE ZAMĚSTNANCI ============================================================ */ function notifyEmployee_( employeeId, title, message ) { const users = displayRows_( CFG.SHEETS.USERS ); const user = users.find( function(row) { return ( String( row.employee_id ) === String( employeeId ) ); } ); if ( !user ) { return; } append_( CFG.SHEETS.NOTIFICATIONS, { notification_id: uuid_( 'NOT' ), user_id: user.user_id, type: 'ATTENDANCE', title: title, message: message, url: '', read_at: '', created_at: now_(), email_sent_at: '' } ); if ( user.email ) { try { MailApp.sendEmail({ to: user.email, subject: CFG.APP_NAME + ' – ' + title, body: message, name: CFG.APP_NAME }); } catch(error) { /* * E-mail nesmí shodit * samotnou uzávěrku. */ Logger.log( 'EMAIL ERROR: ' + error ); } } } /* ============================================================ NOTIFIKACE ADMINŮM / MANAGERŮM ============================================================ */ function notifyManagers_( employeeId, title, message ) { const users = displayRows_( CFG.SHEETS.USERS ); users .filter( function(user) { return [ 'ADMIN', 'MANAGER' ].includes( String( user.role ) ); } ) .forEach( function(user) { append_( CFG.SHEETS.NOTIFICATIONS, { notification_id: uuid_( 'NOT' ), user_id: user.user_id, type: 'ATTENDANCE', title: title, message: message, url: '', read_at: '', created_at: now_(), email_sent_at: '' } ); } ); } /* ============================================================ DIAGNOSTICKÝ TEST Tuhle funkci můžeš ručně spustit. ============================================================ */ function testClosureDatabase() { Logger.log( 'MONTH_CLOSURES: ' + JSON.stringify( displayRows_( CFG.SHEETS.MONTH_CLOSURES ) ) ); Logger.log( 'SHIFTS: ' + JSON.stringify( displayRows_( CFG.SHEETS.SHIFTS ) ) ); Logger.log( 'PAY_RATES: ' + JSON.stringify( displayRows_( CFG.SHEETS.PAY_RATES ) ) ); Logger.log( 'ClosureService OK' ); } function testCreateClosureForEmployee() { const employeeId = 'EMP_6C233E6102D94CE4'; const period = '2026-08'; let closures = displayRows_( CFG.SHEETS.MONTH_CLOSURES ); let closure = closures.find( function(row) { return ( String( row.employee_id ) === employeeId && String( row.period ) === period ); } ); if ( !closure ) { append_( CFG.SHEETS.MONTH_CLOSURES, { closure_id: uuid_( 'CLOSE' ), employee_id: employeeId, period: period, employee_confirmed_at: '', manager_approved_at: '', manager_approved_by: '', locked_at: '', status: 'WAITING_EMPLOYEE' } ); } Logger.log( JSON.stringify( displayRows_( CFG.SHEETS.MONTH_CLOSURES ) ) ); }