Files
eatme/gscript/WeeklyPayrollService.js
Michal Pemcak f917ed06a8 Add shift planning, month closure + payroll, and a dark modern redesign
Ports two modules from the friend's Google Apps Script build (kept as
reference in gscript/) onto the TypeScript stack, rewritten cleanly against
this app's own data model rather than copied 1:1:

- Shift planning: weekly template (Sun-Thu evening, Fri/Sat two slots),
  lazy idempotent generation per period (no cron needed), employee
  signup/cancel with collision + capacity checks, admin calendar view with
  slot editing and manual assignment.
- Month closure + payroll: employee confirms the month (blocked while a
  shift is still open), admin locks and finalizes pay (base hours * rate +
  tips/bonus/other), reopen to undo a premature lock, mark paid. Pay rates
  are versioned by date, defaulting the first-ever rate to apply
  retroactively to the employee's whole history.
- A shift left open more than 12h (forgotten clock-out) is auto-closed at
  clock_in + 12h, checked lazily on read instead of a background job.
- Full dark, sharp-edged modern restyle (theme.css replaces tui.css) with
  an amber accent, keeping every existing class name so no component
  logic needed to change.

Backend test coverage (jest) for all three workflows: shift planning,
closure/payroll, and the forgotten-clock-out auto-close.
2026-08-16 18:41:11 +02:00

3606 lines
47 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
EATME PORTÁL WEEKLY PAYROLL SERVICE
Týdenní výkaz:
- období PoNe
- 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 PONE
============================================================ */
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'
);
}