/* ============================================================ EATME PORTÁL – WEEKLY PAYROLL SERVICE Týdenní výkaz: - období Po–Ne - zaměstnanec potvrzuje nejpozději 12 hodin po skončení poslední plánované směny v daném týdnu - MANAGER / ADMIN doplní spropitné, bonus, ostatní - ADMIN označí týden jako PAID Měsíční PAYROLL / MONTH_CLOSURES zůstávají oddělené. ============================================================ */ const WEEKLY_SHEETS = { CLOSURES: 'WEEKLY_CLOSURES' }; /* ============================================================ JEDNORÁZOVÝ SETUP ============================================================ */ function setupWeeklyPayroll() { ensureWeeklySheet_( WEEKLY_SHEETS.CLOSURES, [ 'weekly_id', 'employee_id', 'week_start', 'week_end', 'last_shift_end', 'deadline_at', 'worked_minutes', 'base_amount', 'tips_amount', 'card_tip_amount', 'bonus_amount', 'sales_bonus_amount', 'other_amount', 'final_amount', 'employee_confirmed_at', 'status', 'paid_at', 'paid_by', 'employee_notified_at', 'reminder_sent_at', 'overdue_notified_at', 'updated_at' ] ); ensureWeeklyPayrollColumns_(); createWeeklyDeadlineTrigger_(); Logger.log( 'Weekly payroll setup OK' ); } /* ============================================================ DOPLNĚNÍ NOVÝCH SLOUPCŮ DO EXISTUJÍCÍHO LISTU ============================================================ */ function ensureWeeklyPayrollColumns_() { const sheet = sh_( WEEKLY_SHEETS.CLOSURES ); const required = [ 'employee_notified_at', 'reminder_sent_at', 'overdue_notified_at' ]; const lastColumn = Math.max( 1, sheet.getLastColumn() ); const headers = sheet .getRange( 1, 1, 1, lastColumn ) .getDisplayValues()[0] .map( function(value) { return String( value ).trim(); } ); required.forEach( function(header) { if ( headers.includes( header ) ) { return; } const column = sheet.getLastColumn() + 1; sheet .getRange( 1, column ) .setValue( header ); headers.push( header ); } ); } /* ============================================================ HODINOVÝ TRIGGER PRO OVERDUE STAV ============================================================ */ function createWeeklyDeadlineTrigger_() { const handler = 'refreshWeeklyDeadlineStatuses'; ScriptApp .getProjectTriggers() .filter( function(trigger) { return ( trigger.getHandlerFunction() === handler ); } ) .forEach( function(trigger) { ScriptApp.deleteTrigger( trigger ); } ); ScriptApp .newTrigger( handler ) .timeBased() .everyHours( 1 ) .create(); } /* ============================================================ ZAMĚSTNANEC – AKTUÁLNÍ / ZVOLENÝ TÝDEN ============================================================ */ function getMyWeeklyReport( token, dateValue ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } const week = getWeekRange_( dateValue ); const data = buildWeeklyData_( user.employee_id, week.start, week.end ); if ( !data.has_activity ) { return { has_activity:false, week_start: week.start, week_end: week.end, status: 'NO_ACTIVITY', worked_minutes: 0, shift_count: 0, base_amount: 0, tips_amount: 0, card_tip_amount: 0, bonus_amount: 0, sales_bonus_amount: 0, other_amount: 0, final_amount: 0, last_shift_end: '', deadline_at: '', employee_confirmed_at: '', paid_at: '', can_confirm: false, overdue: false }; } const closure = ensureWeeklyClosure_( user.employee_id, week.start, week.end, data ); const now = new Date(); const deadline = parseWeeklyDateTime_( closure.deadline_at ); const lastShiftEnd = parseWeeklyDateTime_( closure.last_shift_end ); const status = computeWeeklyStatus_( closure, now ); if ( status !== String( closure.status ) ) { updateBy_( WEEKLY_SHEETS.CLOSURES, 'weekly_id', closure.weekly_id, { status: status, updated_at: now_() } ); } const salesBonusAmount = getSalesBonusForEmployeeWeek_( user.employee_id, week.start, week.end ); const cardTipAmount = getCardTipForEmployeeWeek_( user.employee_id, week.start, week.end ); return { has_activity:true, weekly_id: String( closure.weekly_id || '' ), week_start: String( closure.week_start || week.start ), week_end: String( closure.week_end || week.end ), worked_minutes: Number( data.worked_minutes || 0 ), shift_count: Number( data.shift_count || 0 ), base_amount: Number( data.base_amount || 0 ), tips_amount: weeklyNumber_( closure.tips_amount ), card_tip_amount: cardTipAmount, bonus_amount: weeklyNumber_( closure.bonus_amount ), sales_bonus_amount: salesBonusAmount, other_amount: weeklyNumber_( closure.other_amount ), final_amount: Number( data.base_amount || 0 ) + weeklyNumber_( closure.tips_amount ) + cardTipAmount + weeklyNumber_( closure.bonus_amount ) + salesBonusAmount + weeklyNumber_( closure.other_amount ), last_shift_end: String( closure.last_shift_end || '' ), deadline_at: String( closure.deadline_at || '' ), employee_confirmed_at: String( closure.employee_confirmed_at || '' ), paid_at: String( closure.paid_at || '' ), status: status, can_confirm: ( lastShiftEnd && now >= lastShiftEnd && ![ 'EMPLOYEE_CONFIRMED', 'READY_TO_PAY', 'PAID' ].includes( status ) ), overdue: ( status === 'OVERDUE' ), deadline_remaining_minutes: deadline ? Math.round( ( deadline.getTime() - now.getTime() ) / 60000 ) : null }; } /* ============================================================ ZAMĚSTNANEC – POTVRZENÍ TÝDNE ============================================================ */ function confirmMyWeeklyReport( token, weekStart ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } const week = getWeekRange_( weekStart ); const data = buildWeeklyData_( user.employee_id, week.start, week.end ); if ( !data.has_activity ) { throw new Error( 'Za tento týden není co potvrdit.' ); } const closure = ensureWeeklyClosure_( user.employee_id, week.start, week.end, data ); if ( [ 'EMPLOYEE_CONFIRMED', 'READY_TO_PAY', 'PAID' ].includes( String( closure.status ) ) ) { return { ok:true }; } const now = new Date(); const lastShiftEnd = parseWeeklyDateTime_( closure.last_shift_end ); if ( !lastShiftEnd || now < lastShiftEnd ) { throw new Error( 'Týdenní výkaz lze potvrdit až po skončení poslední směny v týdnu.' ); } updateBy_( WEEKLY_SHEETS.CLOSURES, 'weekly_id', closure.weekly_id, { worked_minutes: data.worked_minutes, base_amount: data.base_amount, final_amount: data.base_amount + weeklyNumber_( closure.tips_amount ) + weeklyNumber_( closure.bonus_amount ) + weeklyNumber_( closure.other_amount ), employee_confirmed_at: now_(), status: 'EMPLOYEE_CONFIRMED', updated_at: now_() } ); audit_( user.user_id, 'WEEKLY_REPORT_CONFIRMED', 'WEEKLY_CLOSURE', closure.weekly_id, closure.status, 'EMPLOYEE_CONFIRMED' ); notifyManagers_( user.employee_id, 'Týdenní výkaz potvrzen', 'Zaměstnanec potvrdil týdenní výkaz za období ' + week.start + ' až ' + week.end + '.' ); return { ok:true }; } /* ============================================================ MANAGER / ADMIN – TÝDENNÍ PŘEHLED ============================================================ */ /* ============================================================ ADMIN / MANAGER – DETAIL DOCHÁZKY PRO TÝDENNÍ VÝPLATU Vrací pouze směny, které vstupují do mzdového základu. ============================================================ */ function getAdminWeeklyAttendanceDetail( token, employeeId, weekStart ) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); employeeId = String( employeeId || '' ); if ( !employeeId ) { throw new Error( 'Chybí zaměstnanec.' ); } const week = getWeekRange_( weekStart ); const context = buildWeeklyRequestContext_(); const employee = context.employees .find( function(row) { return ( String( row.employee_id ) === employeeId ); } ); if ( !employee ) { throw new Error( 'Zaměstnanec nebyl nalezen.' ); } const shifts = context.shiftsByEmployee[ employeeId ] || []; const rates = context.ratesByEmployee[ employeeId ] || []; const dayMap = {}; shifts.forEach( function(shift) { const workDate = normalizeDateOnly_( shift.work_date ); if ( !workDate || workDate < week.start || workDate > week.end ) { return; } /* * Musíme používat stejnou logiku jako buildWeeklyDataFromContext_(), * jinak by se detail mohl lišit od částky ve výplatě. */ if ( String( shift.status ) === 'OPEN' ) { return; } const minutes = weeklyNumber_( shift.worked_minutes ); const breakMinutes = weeklyNumber_( shift.break_minutes ); const clockIn = parseEmployeeDate_( shift.clock_in ); const clockOut = parseEmployeeDate_( shift.clock_out ); const rate = employeeRateAt_( rates, employeeId, clockIn || new Date( workDate ) ); const amount = ( minutes / 60 ) * rate; if ( !dayMap[ workDate ] ) { dayMap[ workDate ] = { work_date: workDate, first_clock_in: null, last_clock_out: null, break_minutes: 0, worked_minutes: 0, base_amount: 0, rates: [], shift_count: 0 }; } const day = dayMap[ workDate ]; if ( clockIn && ( !day.first_clock_in || clockIn < day.first_clock_in ) ) { day.first_clock_in = clockIn; } if ( clockOut && ( !day.last_clock_out || clockOut > day.last_clock_out ) ) { day.last_clock_out = clockOut; } day.break_minutes += breakMinutes; day.worked_minutes += minutes; day.base_amount += amount; day.rates.push( rate ); day.shift_count++; } ); const rows = Object.keys( dayMap ) .sort() .map( function(date) { const day = dayMap[ date ]; const uniqueRates = Array.from( new Set( day.rates.map( function(rate) { return Number( rate || 0 ); } ) ) ); return { work_date: day.work_date, clock_in: day.first_clock_in ? formatWeeklyDateTime_( day.first_clock_in ) : '', clock_out: day.last_clock_out ? formatWeeklyDateTime_( day.last_clock_out ) : '', break_minutes: Math.round( day.break_minutes ), worked_minutes: Math.round( day.worked_minutes ), hourly_rate: uniqueRates.length === 1 ? uniqueRates[0] : null, effective_hourly_rate: day.worked_minutes > 0 ? ( day.base_amount / ( day.worked_minutes / 60 ) ) : 0, base_amount: Math.round( day.base_amount ), shift_count: day.shift_count }; } ); const totalWorkedMinutes = rows.reduce( function(sum,row) { return ( sum + Number( row.worked_minutes || 0 ) ); }, 0 ); const totalBreakMinutes = rows.reduce( function(sum,row) { return ( sum + Number( row.break_minutes || 0 ) ); }, 0 ); const totalBaseAmount = rows.reduce( function(sum,row) { return ( sum + Number( row.base_amount || 0 ) ); }, 0 ); return { employee_id: employeeId, employee_name: ( String( employee.first_name || '' ) + ' ' + String( employee.last_name || '' ) ).trim(), week_start: week.start, week_end: week.end, rows: rows, totals: { worked_minutes: totalWorkedMinutes, break_minutes: totalBreakMinutes, base_amount: totalBaseAmount, day_count: rows.length } }; } /* ============================================================ ADMIN / MANAGER – TÝDENNÍ VÝPLATY ============================================================ */ function getAdminWeeklyPayroll( token, dateValue ) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const week = getWeekRange_( dateValue ); /* * VŠECHNA DATA NAČTEME JEDNOU. * Původní verze znovu četla několik listů pro každého zaměstnance. */ const context = buildWeeklyRequestContext_(); const employees = context.employees .filter( function(employee) { return ( String( employee.active ).toUpperCase() === 'TRUE' ); } ); const result = []; const now = new Date(); employees.forEach( function(employee) { const data = buildWeeklyDataFromContext_( employee.employee_id, week.start, week.end, context ); if ( !data.has_activity ) { return; } const closure = ensureWeeklyClosureWithContext_( employee.employee_id, week.start, week.end, data, context ); const status = computeWeeklyStatus_( closure, now ); const salesBonusAmount = getSalesBonusForEmployeeWeek_( employee.employee_id, week.start, week.end ); const cardTipAmount = getCardTipForEmployeeWeek_( employee.employee_id, week.start, week.end ); result.push( { weekly_id: String( closure.weekly_id || '' ), employee_id: String( employee.employee_id || '' ), employee_name: ( String( employee.first_name || '' ) + ' ' + String( employee.last_name || '' ) ).trim(), week_start: week.start, week_end: week.end, worked_minutes: Number( data.worked_minutes || 0 ), shift_count: Number( data.shift_count || 0 ), base_amount: Number( data.base_amount || 0 ), tips_amount: weeklyNumber_( closure.tips_amount ), card_tip_amount: cardTipAmount, bonus_amount: weeklyNumber_( closure.bonus_amount ), sales_bonus_amount: salesBonusAmount, other_amount: weeklyNumber_( closure.other_amount ), final_amount: Number( data.base_amount || 0 ) + weeklyNumber_( closure.tips_amount ) + cardTipAmount + weeklyNumber_( closure.bonus_amount ) + salesBonusAmount + weeklyNumber_( closure.other_amount ), last_shift_end: String( closure.last_shift_end || '' ), deadline_at: String( closure.deadline_at || '' ), employee_confirmed_at: String( closure.employee_confirmed_at || '' ), paid_at: String( closure.paid_at || '' ), status: status } ); } ); const sortedRows = result.sort( function(a,b) { return a.employee_name.localeCompare( b.employee_name, 'cs' ); } ); const stats = { total: sortedRows.length, waiting: sortedRows.filter( function(row) { return [ 'IN_PROGRESS', 'WAITING_EMPLOYEE' ].includes( row.status ); } ).length, overdue: sortedRows.filter( function(row) { return row.status === 'OVERDUE'; } ).length, confirmed: sortedRows.filter( function(row) { return [ 'EMPLOYEE_CONFIRMED', 'READY_TO_PAY' ].includes( row.status ); } ).length, paid: sortedRows.filter( function(row) { return row.status === 'PAID'; } ).length, total_amount: sortedRows.reduce( function(sum,row) { return sum + Number( row.final_amount || 0 ); }, 0 ) }; return { week_start: week.start, week_end: week.end, stats: stats, rows: sortedRows }; } /* ============================================================ MANAGER / ADMIN – ODMĚNY ============================================================ */ function saveWeeklyAdjustments( token, weeklyId, tipsAmount, bonusAmount, otherAmount ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const closure = findWeeklyClosureById_( weeklyId ); if ( !closure ) { throw new Error( 'Týdenní výkaz nebyl nalezen.' ); } if ( String( closure.status ) === 'PAID' ) { throw new Error( 'Vyplacený týden už nelze měnit.' ); } if ( ![ 'EMPLOYEE_CONFIRMED', 'READY_TO_PAY' ].includes( String( closure.status ) ) ) { throw new Error( 'Odměny lze zadat až po potvrzení výkazu zaměstnancem.' ); } tipsAmount = weeklyNumber_( tipsAmount ); bonusAmount = weeklyNumber_( bonusAmount ); otherAmount = weeklyNumber_( otherAmount ); const baseAmount = weeklyNumber_( closure.base_amount ); const salesBonusAmount = getSalesBonusForEmployeeWeek_( closure.employee_id, closure.week_start, closure.week_end ); const cardTipAmount = getCardTipForEmployeeWeek_( closure.employee_id, closure.week_start, closure.week_end ); const finalAmount = baseAmount + tipsAmount + cardTipAmount + bonusAmount + salesBonusAmount + otherAmount; updateBy_( WEEKLY_SHEETS.CLOSURES, 'weekly_id', weeklyId, { tips_amount: tipsAmount, card_tip_amount: cardTipAmount, bonus_amount: bonusAmount, sales_bonus_amount: salesBonusAmount, other_amount: otherAmount, final_amount: finalAmount, status: 'READY_TO_PAY', updated_at: now_() } ); audit_( user.user_id, 'WEEKLY_ADJUSTMENTS_SAVED', 'WEEKLY_CLOSURE', weeklyId, '', JSON.stringify({ tips_amount: tipsAmount, card_tip_amount: cardTipAmount, bonus_amount: bonusAmount, sales_bonus_amount: salesBonusAmount, other_amount: otherAmount, final_amount: finalAmount }) ); return { ok:true, final_amount: finalAmount }; } /* ============================================================ ADMIN – OZNAČIT VYPLACENO ============================================================ */ function markWeeklyPaid( token, weeklyId ) { const user = requireUser_( token, [ 'ADMIN' ] ); const closure = findWeeklyClosureById_( weeklyId ); if ( !closure ) { throw new Error( 'Týdenní výkaz nebyl nalezen.' ); } if ( ![ 'EMPLOYEE_CONFIRMED', 'READY_TO_PAY' ].includes( String( closure.status ) ) ) { throw new Error( 'Výkaz zatím není připravený k výplatě.' ); } updateBy_( WEEKLY_SHEETS.CLOSURES, 'weekly_id', weeklyId, { status: 'PAID', paid_at: now_(), paid_by: user.user_id, updated_at: now_() } ); audit_( user.user_id, 'WEEKLY_MARKED_PAID', 'WEEKLY_CLOSURE', weeklyId, closure.status, 'PAID' ); notifyEmployee_( closure.employee_id, 'Týdenní výplata vyplacena', 'Výplata za týden ' + closure.week_start + ' až ' + closure.week_end + ' byla označena jako vyplacená.' ); return { ok:true }; } /* ============================================================ AUTOMATICKÁ KONTROLA DEADLINŮ ============================================================ */ function refreshWeeklyDeadlineStatuses() { ensureWeeklyPayrollColumns_(); const closures = displayRows_( WEEKLY_SHEETS.CLOSURES ); const now = new Date(); closures.forEach( function(closure) { const current = String( closure.status || '' ); if ( [ 'EMPLOYEE_CONFIRMED', 'READY_TO_PAY', 'PAID' ].includes( current ) ) { return; } const lastShiftEnd = parseWeeklyDateTime_( closure.last_shift_end ); const deadline = parseWeeklyDateTime_( closure.deadline_at ); if ( !lastShiftEnd || !deadline ) { return; } const status = computeWeeklyStatus_( closure, now ); const update = {}; /* * 1) Jakmile skončí poslední směna, zaměstnanec dostane * upozornění, že má 12 hodin na potvrzení. */ if ( now >= lastShiftEnd && !closure.employee_notified_at ) { notifyEmployee_( closure.employee_id, 'Potvrď týdenní výkaz', 'Skončila tvoje poslední směna v týdnu. Týdenní výkaz potvrď nejpozději do ' + formatWeeklyDateTime_( deadline ) + '.' ); update.employee_notified_at = now_(); } /* * 2) Dvě hodiny před deadlinem pošleme jednu připomínku. */ const reminderAt = new Date( deadline.getTime() - 2 * 60 * 60 * 1000 ); if ( now >= reminderAt && now < deadline && !closure.reminder_sent_at ) { notifyEmployee_( closure.employee_id, 'Týdenní výkaz čeká na potvrzení', 'Na potvrzení týdenního výkazu zbývají méně než 2 hodiny. Deadline je ' + formatWeeklyDateTime_( deadline ) + '.' ); update.reminder_sent_at = now_(); } /* * 3) Po překročení deadlinu upozorníme manažery. */ if ( now > deadline && !closure.overdue_notified_at ) { notifyManagers_( closure.employee_id, 'Týdenní výkaz po termínu', 'Zaměstnanec nepotvrdil týdenní výkaz za období ' + closure.week_start + ' až ' + closure.week_end + ' do stanoveného termínu.' ); update.overdue_notified_at = now_(); } if ( status !== current ) { update.status = status; } if ( Object.keys( update ).length ) { update.updated_at = now_(); updateBy_( WEEKLY_SHEETS.CLOSURES, 'weekly_id', closure.weekly_id, update ); } } ); } /* ============================================================ VÝPOČET DAT TÝDNE ============================================================ */ function buildWeeklyData_( employeeId, weekStart, weekEnd ) { const context = buildWeeklyRequestContext_(); return buildWeeklyDataFromContext_( employeeId, weekStart, weekEnd, context ); } /* ============================================================ POSLEDNÍ PLÁNOVANÁ SMĚNA TÝDNE ============================================================ */ function getLastPlannedShiftEnd_( employeeId, weekStart, weekEnd ) { const context = buildWeeklyRequestContext_(); return getLastPlannedShiftEndFromContext_( employeeId, weekStart, weekEnd, context ); } /* ============================================================ ZALOŽENÍ / OBNOVENÍ TÝDENNÍHO ZÁZNAMU ============================================================ */ function ensureWeeklyClosure_( employeeId, weekStart, weekEnd, data ) { const context = buildWeeklyRequestContext_(); return ensureWeeklyClosureWithContext_( employeeId, weekStart, weekEnd, data, context ); } /* ============================================================ REQUEST CONTEXT – JEDNO ČTENÍ TABULEK NA REQUEST ============================================================ */ function buildWeeklyRequestContext_() { const employees = displayRows_( CFG.SHEETS.EMPLOYEES ); const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const rates = displayRows_( CFG.SHEETS.PAY_RATES ); const slots = displayRows_( 'SHIFT_SLOTS' ); const signups = displayRows_( 'SHIFT_SIGNUPS' ); const closures = displayRows_( WEEKLY_SHEETS.CLOSURES ); const shiftsByEmployee = {}; shifts.forEach( function(shift) { const employeeId = String( shift.employee_id || '' ); if ( !employeeId ) { return; } if ( !shiftsByEmployee[ employeeId ] ) { shiftsByEmployee[ employeeId ] = []; } shiftsByEmployee[ employeeId ].push( shift ); } ); const ratesByEmployee = {}; rates.forEach( function(rate) { const employeeId = String( rate.employee_id || '' ); if ( !employeeId ) { return; } if ( !ratesByEmployee[ employeeId ] ) { ratesByEmployee[ employeeId ] = []; } ratesByEmployee[ employeeId ].push( rate ); } ); const slotMap = {}; slots.forEach( function(slot) { slotMap[ String( slot.slot_id ) ] = slot; } ); const plannedSlotsByEmployee = {}; signups.forEach( function(signup) { if ( String( signup.status ) !== 'APPROVED' ) { return; } const employeeId = String( signup.employee_id || '' ); const slot = slotMap[ String( signup.slot_id ) ]; if ( !employeeId || !slot ) { return; } if ( !plannedSlotsByEmployee[ employeeId ] ) { plannedSlotsByEmployee[ employeeId ] = []; } plannedSlotsByEmployee[ employeeId ].push( slot ); } ); const closuresByKey = {}; closures.forEach( function(closure) { closuresByKey[ weeklyClosureKey_( closure.employee_id, closure.week_start ) ] = closure; } ); return { employees: employees, shiftsByEmployee: shiftsByEmployee, ratesByEmployee: ratesByEmployee, plannedSlotsByEmployee: plannedSlotsByEmployee, closuresByKey: closuresByKey }; } /* ============================================================ VÝPOČET TÝDNE Z PŘEDNAČTENÝCH DAT ============================================================ */ function buildWeeklyDataFromContext_( employeeId, weekStart, weekEnd, context ) { const employeeKey = String( employeeId ); const shifts = context.shiftsByEmployee[ employeeKey ] || []; const rates = context.ratesByEmployee[ employeeKey ] || []; let workedMinutes = 0; let baseAmount = 0; let shiftCount = 0; let latestActualEnd = null; shifts.forEach( function(shift) { const workDate = normalizeDateOnly_( shift.work_date ); if ( !workDate || workDate < weekStart || workDate > weekEnd ) { return; } if ( String( shift.status ) === 'OPEN' ) { return; } const minutes = weeklyNumber_( shift.worked_minutes ); workedMinutes += minutes; if ( minutes > 0 ) { shiftCount++; } const clockIn = parseEmployeeDate_( shift.clock_in ); const rate = employeeRateAt_( rates, employeeId, clockIn || new Date( workDate ) ); baseAmount += ( minutes / 60 ) * rate; const clockOut = parseEmployeeDate_( shift.clock_out ); if ( clockOut && ( !latestActualEnd || clockOut > latestActualEnd ) ) { latestActualEnd = clockOut; } } ); const plannedEnd = getLastPlannedShiftEndFromContext_( employeeId, weekStart, weekEnd, context ); let lastShiftEnd = plannedEnd || latestActualEnd; if ( plannedEnd && latestActualEnd && latestActualEnd > plannedEnd ) { lastShiftEnd = latestActualEnd; } const deadline = lastShiftEnd ? new Date( lastShiftEnd.getTime() + 12 * 60 * 60 * 1000 ) : null; return { has_activity: ( shiftCount > 0 || !!plannedEnd ), worked_minutes: Math.round( workedMinutes ), shift_count: shiftCount, base_amount: Math.round( baseAmount ), last_shift_end: lastShiftEnd ? formatWeeklyDateTime_( lastShiftEnd ) : '', deadline_at: deadline ? formatWeeklyDateTime_( deadline ) : '' }; } /* ============================================================ POSLEDNÍ PLÁNOVANÁ SMĚNA Z PŘEDNAČTENÝCH DAT ============================================================ */ function getLastPlannedShiftEndFromContext_( employeeId, weekStart, weekEnd, context ) { const slots = context.plannedSlotsByEmployee[ String( employeeId ) ] || []; let latest = null; slots.forEach( function(slot) { const date = normalizeDateOnly_( slot.date ); if ( !date || date < weekStart || date > weekEnd ) { return; } const end = weeklySlotEnd_( slot ); if ( end && ( !latest || end > latest ) ) { latest = end; } } ); return latest; } /* ============================================================ UZÁVĚRKA Z REQUEST CONTEXTU Důležité: - tabulku WEEKLY_CLOSURES nečteme znovu pro každého člověka - update děláme pouze pokud se skutečně změnila data ============================================================ */ function ensureWeeklyClosureWithContext_( employeeId, weekStart, weekEnd, data, context ) { const key = weeklyClosureKey_( employeeId, weekStart ); let closure = context.closuresByKey[ key ]; if ( !closure ) { const weeklyId = uuid_( 'WEEK' ); closure = { weekly_id: weeklyId, employee_id: employeeId, week_start: weekStart, week_end: weekEnd, last_shift_end: data.last_shift_end, deadline_at: data.deadline_at, worked_minutes: data.worked_minutes, base_amount: data.base_amount, tips_amount: 0, bonus_amount: 0, sales_bonus_amount: getSalesBonusForEmployeeWeek_( employeeId, weekStart, weekEnd ), other_amount: 0, final_amount: data.base_amount, employee_confirmed_at: '', status: initialWeeklyStatus_( data ), paid_at: '', paid_by: '', updated_at: now_() }; append_( WEEKLY_SHEETS.CLOSURES, closure ); context.closuresByKey[ key ] = closure; return closure; } if ( [ 'EMPLOYEE_CONFIRMED', 'READY_TO_PAY', 'PAID' ].includes( String( closure.status ) ) ) { return closure; } const finalAmount = data.base_amount + weeklyNumber_( closure.tips_amount ) + weeklyNumber_( closure.bonus_amount ) + getSalesBonusForEmployeeWeek_( employeeId, weekStart, weekEnd ) + weeklyNumber_( closure.other_amount ); const changed = String( closure.week_end || '' ) !== String( weekEnd ) || String( closure.last_shift_end || '' ) !== String( data.last_shift_end || '' ) || String( closure.deadline_at || '' ) !== String( data.deadline_at || '' ) || Number( closure.worked_minutes || 0 ) !== Number( data.worked_minutes || 0 ) || Number( closure.base_amount || 0 ) !== Number( data.base_amount || 0 ) || Number( closure.final_amount || 0 ) !== Number( finalAmount || 0 ); if ( changed ) { const update = { week_end: weekEnd, last_shift_end: data.last_shift_end, deadline_at: data.deadline_at, worked_minutes: data.worked_minutes, base_amount: data.base_amount, final_amount: finalAmount, updated_at: now_() }; updateBy_( WEEKLY_SHEETS.CLOSURES, 'weekly_id', closure.weekly_id, update ); Object.keys( update ).forEach( function(field) { closure[ field ] = update[ field ]; } ); } return closure; } /* ============================================================ KLÍČ TÝDENNÍ UZÁVĚRKY ============================================================ */ function weeklyClosureKey_( employeeId, weekStart ) { return ( String( employeeId || '' ) + '|' + String( weekStart || '' ) ); } /* ============================================================ STAVY ============================================================ */ function initialWeeklyStatus_( data ) { const now = new Date(); const lastShiftEnd = parseWeeklyDateTime_( data.last_shift_end ); const deadline = parseWeeklyDateTime_( data.deadline_at ); if ( lastShiftEnd && now < lastShiftEnd ) { return 'IN_PROGRESS'; } if ( deadline && now > deadline ) { return 'OVERDUE'; } return 'WAITING_EMPLOYEE'; } function computeWeeklyStatus_( closure, now ) { const current = String( closure.status || '' ); if ( [ 'EMPLOYEE_CONFIRMED', 'READY_TO_PAY', 'PAID' ].includes( current ) ) { return current; } const lastShiftEnd = parseWeeklyDateTime_( closure.last_shift_end ); const deadline = parseWeeklyDateTime_( closure.deadline_at ); if ( lastShiftEnd && now < lastShiftEnd ) { return 'IN_PROGRESS'; } if ( deadline && now > deadline ) { return 'OVERDUE'; } return 'WAITING_EMPLOYEE'; } /* ============================================================ TÝDEN PO–NE ============================================================ */ function getWeekRange_( dateValue ) { let date; if ( dateValue && /^\d{4}-\d{2}-\d{2}$/.test( String( dateValue ) ) ) { const parts = String( dateValue ).split( '-' ); date = new Date( Number( parts[0] ), Number( parts[1] ) - 1, Number( parts[2] ), 12, 0, 0 ); } else { date = new Date(); } const day = date.getDay(); const mondayOffset = day === 0 ? -6 : 1 - day; const monday = new Date( date ); monday.setDate( monday.getDate() + mondayOffset ); const sunday = new Date( monday ); sunday.setDate( sunday.getDate() + 6 ); return { start: Utilities.formatDate( monday, CFG.TZ, 'yyyy-MM-dd' ), end: Utilities.formatDate( sunday, CFG.TZ, 'yyyy-MM-dd' ) }; } /* ============================================================ HELPERS ============================================================ */ function findWeeklyClosureById_( weeklyId ) { return displayRows_( WEEKLY_SHEETS.CLOSURES ) .find( function(row) { return ( String( row.weekly_id ) === String( weeklyId ) ); } ) || null; } function normalizeDateOnly_( value ) { if ( !value ) { return ''; } if ( value instanceof Date ) { return Utilities.formatDate( value, CFG.TZ, 'yyyy-MM-dd' ); } const text = String( value ); const iso = text.match( /^(\d{4}-\d{2}-\d{2})/ ); if ( iso ) { return iso[1]; } const date = new Date( text ); if ( isNaN( date.getTime() ) ) { return ''; } return Utilities.formatDate( date, CFG.TZ, 'yyyy-MM-dd' ); } function weeklySlotEnd_( slot ) { const date = normalizeDateOnly_( slot.date ); if ( !date ) { return null; } const dateParts = date.split( '-' ); const startParts = String( slot.start_time || '00:00' ).split( ':' ); const endParts = String( slot.end_time || '00:00' ).split( ':' ); const start = new Date( Number( dateParts[0] ), Number( dateParts[1] ) - 1, Number( dateParts[2] ), Number( startParts[0] ), Number( startParts[1] || 0 ), 0 ); const end = new Date( Number( dateParts[0] ), Number( dateParts[1] ) - 1, Number( dateParts[2] ), Number( endParts[0] ), Number( endParts[1] || 0 ), 0 ); if ( end <= start ) { end.setDate( end.getDate() + 1 ); } return end; } function formatWeeklyDateTime_( date ) { return Utilities.formatDate( date, CFG.TZ, 'yyyy-MM-dd HH:mm:ss' ); } function parseWeeklyDateTime_( value ) { if ( !value ) { return null; } if ( value instanceof Date ) { return value; } const text = String( value ).trim(); const match = text.match( /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/ ); if ( match ) { return new Date( Number( match[1] ), Number( match[2] ) - 1, Number( match[3] ), Number( match[4] ), Number( match[5] ), Number( match[6] || 0 ) ); } const date = new Date( text ); return isNaN( date.getTime() ) ? null : date; } function weeklyNumber_( value ) { const number = Number( String( value == null ? 0 : value ) .replace( ',', '.' ) ); return isFinite( number ) ? number : 0; } /* ============================================================ VYTVOŘENÍ LISTU ============================================================ */ function ensureWeeklySheet_( sheetName, headers ) { const employeeSheet = sh_( CFG.SHEETS.EMPLOYEES ); const spreadsheet = employeeSheet .getParent(); let sheet = spreadsheet .getSheetByName( sheetName ); if ( !sheet ) { sheet = spreadsheet .insertSheet( sheetName ); } if ( sheet.getLastRow() === 0 ) { sheet .getRange( 1, 1, 1, headers.length ) .setValues( [ headers ] ); sheet.setFrozenRows( 1 ); } return sheet; } /* ============================================================ DIAGNOSTIKA ============================================================ */ function testWeeklyPayroll() { Logger.log( 'WEEKLY_CLOSURES: ' + JSON.stringify( displayRows_( WEEKLY_SHEETS.CLOSURES ) ) ); Logger.log( 'WeeklyPayrollService OK' ); }