Files
eatme/gscript/PayrollService.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

1554 lines
21 KiB
JavaScript
Raw 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 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'
);
}