/* ============================================================ EATME PORTÁL – PAYROLL SERVICE Role: - MANAGER / ADMIN: spropitné, bonusy, jiné položky před uzamčením - ACCOUNTANT / ADMIN: účetní zpracování mzdy ============================================================ */ /* ============================================================ MANAGER / ADMIN – NAČTENÍ ODMĚN PRO KONKRÉTNÍ MĚSÍC ============================================================ */ function getPayrollAdjustments( token, employeeId, period ) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); period = String( period || '' ).trim(); if ( !/^\d{4}-\d{2}$/.test( period ) ) { throw new Error( 'Neplatné období.' ); } const payrolls = displayRows_( CFG.SHEETS.PAYROLL ); const payroll = payrolls.find( function(row) { return ( String( row.employee_id ) === String( employeeId ) && String( row.period ) === period ); } ); const summary = getClosureSummary_( employeeId, period ); return { payroll_id: payroll ? String( payroll.payroll_id || '' ) : '', employee_id: String( employeeId || '' ), period: period, base_amount: Number( summary.earned_estimate || 0 ), tips_amount: payroll ? numberFromSheet_( payroll.tips_amount ) : 0, bonus_amount: payroll ? numberFromSheet_( payroll.bonus_amount ) : 0, other_amount: payroll ? numberFromSheet_( payroll.other_amount ) : 0, final_amount: payroll ? numberFromSheet_( payroll.final_amount ) : Number( summary.earned_estimate || 0 ), status: payroll ? String( payroll.status || '' ) : 'DRAFT_MANAGER' }; } /* ============================================================ MANAGER / ADMIN – ULOŽENÍ SPROPITNÉHO A ODMĚN ============================================================ */ function savePayrollAdjustments( token, employeeId, period, tipsAmount, bonusAmount, otherAmount ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); period = String( period || '' ).trim(); if ( !/^\d{4}-\d{2}$/.test( period ) ) { throw new Error( 'Neplatné období.' ); } tipsAmount = payrollNumber_( tipsAmount ); bonusAmount = payrollNumber_( bonusAmount ); otherAmount = payrollNumber_( otherAmount ); const closures = displayRows_( CFG.SHEETS.MONTH_CLOSURES ); const closure = closures.find( function(row) { return ( String( row.employee_id ) === String( employeeId ) && String( row.period ) === period ); } ); if ( !closure ) { throw new Error( 'Měsíční uzávěrka nebyla nalezena.' ); } if ( String( closure.status ) === 'LOCKED' ) { throw new Error( 'Uzamčenou mzdu už nelze měnit.' ); } if ( ![ 'EMPLOYEE_CONFIRMED', 'MANAGER_APPROVED' ].includes( String( closure.status ) ) ) { throw new Error( 'Odměny lze zadat až po potvrzení docházky zaměstnancem.' ); } const summary = getClosureSummary_( employeeId, period ); const baseAmount = Number( summary.earned_estimate || 0 ); const finalAmount = baseAmount + tipsAmount + bonusAmount + otherAmount; const payrolls = displayRows_( CFG.SHEETS.PAYROLL ); const existing = payrolls.find( function(row) { return ( String( row.employee_id ) === String( employeeId ) && String( row.period ) === period ); } ); if ( existing && [ 'READY_FOR_ACCOUNTANT', 'PROCESSING', 'PROCESSED', 'PAID' ].includes( String( existing.status ) ) ) { throw new Error( 'Mzdový podklad už byl předán účetní a nelze jej tímto způsobem měnit.' ); } if ( existing ) { updateBy_( CFG.SHEETS.PAYROLL, 'payroll_id', existing.payroll_id, { approved_minutes: summary.worked_minutes, base_amount: baseAmount, tips_amount: tipsAmount, bonus_amount: bonusAmount, other_amount: otherAmount, final_amount: finalAmount, status: 'DRAFT_MANAGER', updated_at: now_() } ); } else { append_( CFG.SHEETS.PAYROLL, { payroll_id: uuid_( 'PAY' ), employee_id: employeeId, period: period, approved_minutes: summary.worked_minutes, base_amount: baseAmount, bonus_amount: bonusAmount, tips_amount: tipsAmount, other_amount: otherAmount, final_amount: finalAmount, status: 'DRAFT_MANAGER', payment_date: '', updated_at: now_() } ); } audit_( user.user_id, 'PAYROLL_ADJUSTMENTS_SAVED', 'PAYROLL', String( employeeId ) + ':' + period, '', JSON.stringify({ tips_amount: tipsAmount, bonus_amount: bonusAmount, other_amount: otherAmount, final_amount: finalAmount }) ); return { ok: true, final_amount: finalAmount }; } /* ============================================================ ADMIN – UZAMČENÍ A PŘEDÁNÍ ÚČETNÍ Tato funkce nahrazuje původní lockMonthClosure z frontendu. ============================================================ */ function adminLockClosureAndPreparePayroll( token, closureId ) { const user = requireUser_( token, [ 'ADMIN' ] ); const lock = LockService .getScriptLock(); lock.waitLock( 10000 ); try { 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 předat účetní až po schválení.' ); } const summary = getClosureSummary_( closure.employee_id, closure.period ); const payrolls = displayRows_( CFG.SHEETS.PAYROLL ); const existing = payrolls.find( function(row) { return ( String( row.employee_id ) === String( closure.employee_id ) && String( row.period ) === String( closure.period ) ); } ); const tipsAmount = existing ? numberFromSheet_( existing.tips_amount ) : 0; const bonusAmount = existing ? numberFromSheet_( existing.bonus_amount ) : 0; const otherAmount = existing ? numberFromSheet_( existing.other_amount ) : 0; const baseAmount = Number( summary.earned_estimate || 0 ); const finalAmount = baseAmount + tipsAmount + bonusAmount + otherAmount; if ( existing ) { updateBy_( CFG.SHEETS.PAYROLL, 'payroll_id', existing.payroll_id, { approved_minutes: summary.worked_minutes, base_amount: baseAmount, tips_amount: tipsAmount, bonus_amount: bonusAmount, other_amount: otherAmount, final_amount: finalAmount, status: 'READY_FOR_ACCOUNTANT', updated_at: now_() } ); } else { append_( CFG.SHEETS.PAYROLL, { payroll_id: uuid_( 'PAY' ), employee_id: closure.employee_id, period: closure.period, approved_minutes: summary.worked_minutes, base_amount: baseAmount, bonus_amount: bonusAmount, tips_amount: tipsAmount, other_amount: otherAmount, final_amount: finalAmount, status: 'READY_FOR_ACCOUNTANT', payment_date: '', updated_at: now_() } ); } updateBy_( CFG.SHEETS.MONTH_CLOSURES, 'closure_id', closureId, { locked_at: now_(), status: 'LOCKED' } ); setMonthShiftStatus_( closure.employee_id, closure.period, 'LOCKED' ); audit_( user.user_id, 'MONTH_LOCKED_AND_SENT_TO_ACCOUNTANT', '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 mzdovému zpracování.' ); return { ok: true, final_amount: finalAmount }; } finally { lock.releaseLock(); } } /* ============================================================ ACCOUNTANT / ADMIN – MZDOVÝ DASHBOARD ============================================================ */ function getPayrollDashboard( token, period ) { const user = requireUser_( token, [ 'ADMIN', 'ACCOUNTANT' ] ); period = String( period || '' ).trim(); const employees = displayRows_( CFG.SHEETS.EMPLOYEES ); const employeeMap = {}; employees.forEach( function(employee) { employeeMap[ String( employee.employee_id ) ] = employee; } ); let payrolls = displayRows_( CFG.SHEETS.PAYROLL ); if ( period ) { payrolls = payrolls.filter( function(row) { return ( String( row.period ) === period ); } ); } payrolls = payrolls.filter( function(row) { return [ 'READY_FOR_ACCOUNTANT', 'PROCESSING', 'PROCESSED', 'PAID' ].includes( String( row.status ) ); } ); const rows = payrolls .map( function(payroll) { const employee = employeeMap[ String( payroll.employee_id ) ]; const baseAmount = numberFromSheet_( payroll.base_amount ); const tipsAmount = numberFromSheet_( payroll.tips_amount ); const bonusAmount = numberFromSheet_( payroll.bonus_amount ); const otherAmount = numberFromSheet_( payroll.other_amount ); const finalAmount = baseAmount + tipsAmount + bonusAmount + otherAmount; return { payroll_id: String( payroll.payroll_id || '' ), employee_id: String( payroll.employee_id || '' ), employee_name: employee ? ( String( employee.first_name || '' ) + ' ' + String( employee.last_name || '' ) ).trim() : String( payroll.employee_id || '' ), employment_type: employee ? String( employee.employment_type || '' ) : '', period: String( payroll.period || '' ), approved_minutes: numberFromSheet_( payroll.approved_minutes ), base_amount: baseAmount, tips_amount: tipsAmount, bonus_amount: bonusAmount, other_amount: otherAmount, final_amount: finalAmount, status: String( payroll.status || '' ), payment_date: String( payroll.payment_date || '' ) }; } ) .sort( function(a,b) { if ( a.period !== b.period ) { return b.period.localeCompare( a.period ); } return a.employee_name.localeCompare( b.employee_name, 'cs' ); } ); const stats = { count: rows.length, total_base: 0, total_tips: 0, total_bonus: 0, total_other: 0, total_final: 0, ready: 0, processing: 0, processed: 0, paid: 0 }; rows.forEach( function(row) { stats.total_base += row.base_amount; stats.total_tips += row.tips_amount; stats.total_bonus += row.bonus_amount; stats.total_other += row.other_amount; stats.total_final += row.final_amount; if ( row.status === 'READY_FOR_ACCOUNTANT' ) { stats.ready++; } if ( row.status === 'PROCESSING' ) { stats.processing++; } if ( row.status === 'PROCESSED' ) { stats.processed++; } if ( row.status === 'PAID' ) { stats.paid++; } } ); return { role: String( user.role || '' ), period: period, stats: stats, rows: rows }; } /* ============================================================ ACCOUNTANT / ADMIN – ZMĚNA STAVU ============================================================ */ function setPayrollStatus( token, payrollId, newStatus ) { const user = requireUser_( token, [ 'ADMIN', 'ACCOUNTANT' ] ); newStatus = String( newStatus || '' ).trim(); const allowed = [ 'READY_FOR_ACCOUNTANT', 'PROCESSING', 'PROCESSED', 'PAID' ]; if ( !allowed.includes( newStatus ) ) { throw new Error( 'Neplatný stav mzdy.' ); } const payrolls = displayRows_( CFG.SHEETS.PAYROLL ); const payroll = payrolls.find( function(row) { return ( String( row.payroll_id ) === String( payrollId ) ); } ); if ( !payroll ) { throw new Error( 'Mzdový podklad nebyl nalezen.' ); } const currentStatus = String( payroll.status || '' ); const order = { READY_FOR_ACCOUNTANT: 1, PROCESSING: 2, PROCESSED: 3, PAID: 4 }; if ( order[ newStatus ] < order[ currentStatus ] ) { throw new Error( 'Stav mzdy nelze vracet zpět.' ); } const update = { status: newStatus, updated_at: now_() }; if ( newStatus === 'PAID' ) { update.payment_date = Utilities.formatDate( new Date(), CFG.TZ, 'yyyy-MM-dd' ); } updateBy_( CFG.SHEETS.PAYROLL, 'payroll_id', payrollId, update ); audit_( user.user_id, 'PAYROLL_STATUS_CHANGED', 'PAYROLL', payrollId, currentStatus, newStatus ); if ( newStatus === 'PROCESSED' ) { notifyEmployee_( payroll.employee_id, 'Mzda byla zpracována', 'Mzda za ' + payroll.period + ' byla zpracována účetní.' ); } if ( newStatus === 'PAID' ) { notifyEmployee_( payroll.employee_id, 'Mzda byla označena jako vyplacená', 'Mzda za ' + payroll.period + ' byla označena jako vyplacená.' ); } return { ok: true }; } /* ============================================================ ACCOUNTANT / ADMIN – CSV EXPORT ============================================================ */ function exportPayrollCsv( token, period ) { requireUser_( token, [ 'ADMIN', 'ACCOUNTANT' ] ); const data = getPayrollDashboard( token, period ); const rows = [ [ 'Zaměstnanec', 'Typ vztahu', 'Období', 'Schválené hodiny', 'Základ', 'Spropitné', 'Bonusy', 'Ostatní', 'Celkem', 'Stav', 'Datum výplaty' ] ]; data.rows.forEach( function(row) { rows.push( [ row.employee_name, row.employment_type, row.period, ( row.approved_minutes / 60 ).toFixed( 2 ), row.base_amount, row.tips_amount, row.bonus_amount, row.other_amount, row.final_amount, row.status, row.payment_date ] ); } ); const csv = rows .map( function(row) { return row .map( function(value) { const text = String( value == null ? '' : value ); return ( '"' + text.replace( /"/g, '""' ) + '"' ); } ) .join( ';' ); } ) .join( '\r\n' ); return { filename: 'EatMe_Portal_mzdy_' + ( period || 'vse' ) + '.csv', content: '\uFEFF' + csv }; } /* ============================================================ POMOCNÁ FUNKCE – ČÍSELNÁ HODNOTA ============================================================ */ function payrollNumber_( value ) { const number = Number( String( value == null ? 0 : value ) .replace( ',', '.' ) ); if ( !isFinite( number ) ) { throw new Error( 'Částka musí být číslo.' ); } return Math.round( number * 100 ) / 100; } /* ============================================================ DIAGNOSTIKA ============================================================ */ function testPayrollDatabase() { Logger.log( 'PAYROLL: ' + JSON.stringify( displayRows_( CFG.SHEETS.PAYROLL ) ) ); Logger.log( 'PayrollService OK' ); }