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.
1592 lines
19 KiB
JavaScript
1592 lines
19 KiB
JavaScript
/* ============================================================
|
||
EATME PORTÁL – SHIFT RECONCILIATION SERVICE
|
||
|
||
Porovnává:
|
||
- plánované směny: SHIFT_SLOTS + SHIFT_SIGNUPS
|
||
- skutečnou docházku: SHIFTS
|
||
|
||
Nic nepřepisuje. Jde o read-only analytickou vrstvu.
|
||
============================================================ */
|
||
|
||
const SHIFT_RECONCILIATION = Object.freeze({
|
||
LATE_TOLERANCE_MINUTES: 10,
|
||
EARLY_LEAVE_TOLERANCE_MINUTES: 10,
|
||
MATCH_WINDOW_HOURS: 8
|
||
});
|
||
|
||
|
||
/* ============================================================
|
||
ZAMĚSTNANEC – PLÁN VS DOCHÁZKA
|
||
============================================================ */
|
||
|
||
function getMyShiftReconciliation(
|
||
token,
|
||
period
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'EMPLOYEE',
|
||
'MANAGER',
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
if (
|
||
!user.employee_id
|
||
) {
|
||
|
||
throw new Error(
|
||
'Účet není propojen se zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
period =
|
||
normalizeReconciliationPeriod_(
|
||
period
|
||
);
|
||
|
||
|
||
const context =
|
||
buildReconciliationContext_();
|
||
|
||
|
||
const rows =
|
||
reconcileEmployeePeriod_(
|
||
user.employee_id,
|
||
period,
|
||
context
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
period:
|
||
period,
|
||
|
||
stats:
|
||
reconciliationStats_(
|
||
rows
|
||
),
|
||
|
||
rows:
|
||
rows
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN / MANAGER – PLÁN VS DOCHÁZKA
|
||
============================================================ */
|
||
|
||
function getAdminShiftReconciliation(
|
||
token,
|
||
period
|
||
) {
|
||
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
period =
|
||
normalizeReconciliationPeriod_(
|
||
period
|
||
);
|
||
|
||
|
||
const context =
|
||
buildReconciliationContext_();
|
||
|
||
|
||
const result =
|
||
[];
|
||
|
||
|
||
context.employees
|
||
.filter(
|
||
function(employee) {
|
||
|
||
return (
|
||
String(
|
||
employee.active
|
||
).toUpperCase() ===
|
||
'TRUE'
|
||
);
|
||
|
||
}
|
||
)
|
||
.forEach(
|
||
function(employee) {
|
||
|
||
const rows =
|
||
reconcileEmployeePeriod_(
|
||
employee.employee_id,
|
||
period,
|
||
context
|
||
);
|
||
|
||
|
||
const employeeName =
|
||
(
|
||
String(
|
||
employee.first_name ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
employee.last_name ||
|
||
''
|
||
)
|
||
).trim();
|
||
|
||
|
||
rows.forEach(
|
||
function(row) {
|
||
|
||
row.employee_id =
|
||
String(
|
||
employee.employee_id ||
|
||
''
|
||
);
|
||
|
||
|
||
row.employee_name =
|
||
employeeName;
|
||
|
||
|
||
result.push(
|
||
row
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
result.sort(
|
||
function(a,b) {
|
||
|
||
if (
|
||
a.date !==
|
||
b.date
|
||
) {
|
||
|
||
return String(
|
||
a.date
|
||
).localeCompare(
|
||
String(
|
||
b.date
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
a.planned_start !==
|
||
b.planned_start
|
||
) {
|
||
|
||
return String(
|
||
a.planned_start ||
|
||
a.actual_start ||
|
||
''
|
||
).localeCompare(
|
||
String(
|
||
b.planned_start ||
|
||
b.actual_start ||
|
||
''
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return String(
|
||
a.employee_name ||
|
||
''
|
||
).localeCompare(
|
||
String(
|
||
b.employee_name ||
|
||
''
|
||
),
|
||
'cs'
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
period:
|
||
period,
|
||
|
||
stats:
|
||
reconciliationStats_(
|
||
result
|
||
),
|
||
|
||
rows:
|
||
result
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
KONTEXT – KAŽDÝ LIST NAČTEME JEN JEDNOU
|
||
============================================================ */
|
||
|
||
function buildReconciliationContext_() {
|
||
|
||
const slots =
|
||
displayRows_(
|
||
'SHIFT_SLOTS'
|
||
);
|
||
|
||
|
||
const signups =
|
||
displayRows_(
|
||
'SHIFT_SIGNUPS'
|
||
);
|
||
|
||
|
||
const shifts =
|
||
displayRows_(
|
||
CFG.SHEETS.SHIFTS
|
||
);
|
||
|
||
|
||
const employees =
|
||
displayRows_(
|
||
CFG.SHEETS.EMPLOYEES
|
||
);
|
||
|
||
|
||
const slotMap =
|
||
{};
|
||
|
||
|
||
slots.forEach(
|
||
function(slot) {
|
||
|
||
slotMap[
|
||
String(
|
||
slot.slot_id
|
||
)
|
||
] =
|
||
slot;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const plannedByEmployee =
|
||
{};
|
||
|
||
|
||
signups.forEach(
|
||
function(signup) {
|
||
|
||
if (
|
||
String(
|
||
signup.status
|
||
) !==
|
||
'APPROVED'
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const slot =
|
||
slotMap[
|
||
String(
|
||
signup.slot_id
|
||
)
|
||
];
|
||
|
||
|
||
if (
|
||
!slot
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const employeeId =
|
||
String(
|
||
signup.employee_id ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
!plannedByEmployee[
|
||
employeeId
|
||
]
|
||
) {
|
||
|
||
plannedByEmployee[
|
||
employeeId
|
||
] =
|
||
[];
|
||
|
||
}
|
||
|
||
|
||
plannedByEmployee[
|
||
employeeId
|
||
].push(
|
||
{
|
||
|
||
signup:
|
||
signup,
|
||
|
||
slot:
|
||
slot
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const actualByEmployee =
|
||
{};
|
||
|
||
|
||
shifts.forEach(
|
||
function(shift) {
|
||
|
||
const employeeId =
|
||
String(
|
||
shift.employee_id ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
!actualByEmployee[
|
||
employeeId
|
||
]
|
||
) {
|
||
|
||
actualByEmployee[
|
||
employeeId
|
||
] =
|
||
[];
|
||
|
||
}
|
||
|
||
|
||
actualByEmployee[
|
||
employeeId
|
||
].push(
|
||
shift
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
employees:
|
||
employees,
|
||
|
||
plannedByEmployee:
|
||
plannedByEmployee,
|
||
|
||
actualByEmployee:
|
||
actualByEmployee
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
PÁROVÁNÍ JEDNOHO ZAMĚSTNANCE
|
||
============================================================ */
|
||
|
||
function reconcileEmployeePeriod_(
|
||
employeeId,
|
||
period,
|
||
context
|
||
) {
|
||
|
||
const planned =
|
||
(
|
||
context.plannedByEmployee[
|
||
String(
|
||
employeeId
|
||
)
|
||
] ||
|
||
[]
|
||
)
|
||
.filter(
|
||
function(item) {
|
||
|
||
return (
|
||
String(
|
||
item.slot.date ||
|
||
''
|
||
).substring(
|
||
0,
|
||
7
|
||
) ===
|
||
period
|
||
);
|
||
|
||
}
|
||
)
|
||
.map(
|
||
function(item) {
|
||
|
||
return makePlannedItem_(
|
||
item
|
||
);
|
||
|
||
}
|
||
)
|
||
.filter(
|
||
Boolean
|
||
)
|
||
.sort(
|
||
function(a,b) {
|
||
|
||
return (
|
||
a.start.getTime() -
|
||
b.start.getTime()
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const actual =
|
||
(
|
||
context.actualByEmployee[
|
||
String(
|
||
employeeId
|
||
)
|
||
] ||
|
||
[]
|
||
)
|
||
.map(
|
||
function(shift) {
|
||
|
||
return makeActualItem_(
|
||
shift
|
||
);
|
||
|
||
}
|
||
)
|
||
.filter(
|
||
function(item) {
|
||
|
||
if (
|
||
!item ||
|
||
!item.start
|
||
) {
|
||
|
||
return false;
|
||
|
||
}
|
||
|
||
|
||
return (
|
||
Utilities.formatDate(
|
||
item.start,
|
||
CFG.TZ,
|
||
'yyyy-MM'
|
||
) ===
|
||
period
|
||
);
|
||
|
||
}
|
||
)
|
||
.sort(
|
||
function(a,b) {
|
||
|
||
return (
|
||
a.start.getTime() -
|
||
b.start.getTime()
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const usedActual =
|
||
{};
|
||
|
||
|
||
const rows =
|
||
[];
|
||
|
||
|
||
planned.forEach(
|
||
function(plan) {
|
||
|
||
let bestIndex =
|
||
-1;
|
||
|
||
|
||
let bestDistance =
|
||
Infinity;
|
||
|
||
|
||
actual.forEach(
|
||
function(attendance,index) {
|
||
|
||
if (
|
||
usedActual[
|
||
index
|
||
]
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const distance =
|
||
Math.abs(
|
||
attendance.start.getTime() -
|
||
plan.start.getTime()
|
||
);
|
||
|
||
|
||
if (
|
||
distance >
|
||
SHIFT_RECONCILIATION.MATCH_WINDOW_HOURS *
|
||
3600000
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
/*
|
||
* Přednost má stejný pracovní den.
|
||
*/
|
||
|
||
const planDate =
|
||
Utilities.formatDate(
|
||
plan.start,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
);
|
||
|
||
|
||
const actualDate =
|
||
Utilities.formatDate(
|
||
attendance.start,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
);
|
||
|
||
|
||
const penalty =
|
||
planDate ===
|
||
actualDate
|
||
? 0
|
||
: 24 *
|
||
3600000;
|
||
|
||
|
||
const score =
|
||
distance +
|
||
penalty;
|
||
|
||
|
||
if (
|
||
score <
|
||
bestDistance
|
||
) {
|
||
|
||
bestDistance =
|
||
score;
|
||
|
||
bestIndex =
|
||
index;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
bestIndex >=
|
||
0
|
||
) {
|
||
|
||
usedActual[
|
||
bestIndex
|
||
] =
|
||
true;
|
||
|
||
|
||
rows.push(
|
||
reconciliationRow_(
|
||
plan,
|
||
actual[
|
||
bestIndex
|
||
]
|
||
)
|
||
);
|
||
|
||
} else {
|
||
|
||
rows.push(
|
||
reconciliationRow_(
|
||
plan,
|
||
null
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
actual.forEach(
|
||
function(attendance,index) {
|
||
|
||
if (
|
||
usedActual[
|
||
index
|
||
]
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
rows.push(
|
||
reconciliationRow_(
|
||
null,
|
||
attendance
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return rows.sort(
|
||
function(a,b) {
|
||
|
||
const aKey =
|
||
String(
|
||
a.date ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
a.planned_start ||
|
||
a.actual_start ||
|
||
''
|
||
);
|
||
|
||
|
||
const bKey =
|
||
String(
|
||
b.date ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
b.planned_start ||
|
||
b.actual_start ||
|
||
''
|
||
);
|
||
|
||
|
||
return aKey.localeCompare(
|
||
bKey
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
PLÁNOVANÁ SMĚNA
|
||
============================================================ */
|
||
|
||
function makePlannedItem_(item) {
|
||
|
||
const slot =
|
||
item.slot;
|
||
|
||
|
||
const date =
|
||
String(
|
||
slot.date ||
|
||
''
|
||
).substring(
|
||
0,
|
||
10
|
||
);
|
||
|
||
|
||
if (
|
||
!date
|
||
) {
|
||
|
||
return null;
|
||
|
||
}
|
||
|
||
|
||
const start =
|
||
reconciliationDateTime_(
|
||
date,
|
||
slot.start_time
|
||
);
|
||
|
||
|
||
let end =
|
||
reconciliationDateTime_(
|
||
date,
|
||
slot.end_time
|
||
);
|
||
|
||
|
||
if (
|
||
!start ||
|
||
!end
|
||
) {
|
||
|
||
return null;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
end <=
|
||
start
|
||
) {
|
||
|
||
end =
|
||
new Date(
|
||
end.getTime() +
|
||
86400000
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return {
|
||
|
||
slot_id:
|
||
String(
|
||
slot.slot_id ||
|
||
''
|
||
),
|
||
|
||
date:
|
||
date,
|
||
|
||
start:
|
||
start,
|
||
|
||
end:
|
||
end,
|
||
|
||
start_time:
|
||
reconciliationHHMM_(
|
||
start
|
||
),
|
||
|
||
end_time:
|
||
reconciliationHHMM_(
|
||
end
|
||
)
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
SKUTEČNÁ SMĚNA
|
||
============================================================ */
|
||
|
||
function makeActualItem_(shift) {
|
||
|
||
const start =
|
||
reconciliationParseDate_(
|
||
shift.clock_in
|
||
);
|
||
|
||
|
||
if (
|
||
!start
|
||
) {
|
||
|
||
return null;
|
||
|
||
}
|
||
|
||
|
||
const end =
|
||
reconciliationParseDate_(
|
||
shift.clock_out
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
shift_id:
|
||
String(
|
||
shift.shift_id ||
|
||
''
|
||
),
|
||
|
||
start:
|
||
start,
|
||
|
||
end:
|
||
end,
|
||
|
||
status:
|
||
String(
|
||
shift.status ||
|
||
''
|
||
),
|
||
|
||
worked_minutes:
|
||
reconciliationNumber_(
|
||
shift.worked_minutes
|
||
)
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
VÝSLEDNÝ ŘÁDEK
|
||
============================================================ */
|
||
|
||
function reconciliationRow_(
|
||
plan,
|
||
attendance
|
||
) {
|
||
|
||
const now =
|
||
new Date();
|
||
|
||
|
||
let status =
|
||
'OK';
|
||
|
||
|
||
let label =
|
||
'V pořádku';
|
||
|
||
|
||
let lateMinutes =
|
||
0;
|
||
|
||
|
||
let earlyLeaveMinutes =
|
||
0;
|
||
|
||
|
||
let differenceMinutes =
|
||
0;
|
||
|
||
|
||
if (
|
||
plan &&
|
||
!attendance
|
||
) {
|
||
|
||
if (
|
||
now <
|
||
plan.end
|
||
) {
|
||
|
||
status =
|
||
'PLANNED';
|
||
|
||
label =
|
||
'Plánováno';
|
||
|
||
} else {
|
||
|
||
status =
|
||
'ABSENT';
|
||
|
||
label =
|
||
'Bez docházky';
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
!plan &&
|
||
attendance
|
||
) {
|
||
|
||
status =
|
||
'EXTRA';
|
||
|
||
label =
|
||
'Směna navíc';
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
plan &&
|
||
attendance
|
||
) {
|
||
|
||
lateMinutes =
|
||
Math.max(
|
||
0,
|
||
Math.round(
|
||
(
|
||
attendance.start.getTime() -
|
||
plan.start.getTime()
|
||
) /
|
||
60000
|
||
)
|
||
);
|
||
|
||
|
||
if (
|
||
attendance.end
|
||
) {
|
||
|
||
earlyLeaveMinutes =
|
||
Math.max(
|
||
0,
|
||
Math.round(
|
||
(
|
||
plan.end.getTime() -
|
||
attendance.end.getTime()
|
||
) /
|
||
60000
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const plannedMinutes =
|
||
Math.round(
|
||
(
|
||
plan.end.getTime() -
|
||
plan.start.getTime()
|
||
) /
|
||
60000
|
||
);
|
||
|
||
|
||
differenceMinutes =
|
||
attendance.worked_minutes -
|
||
plannedMinutes;
|
||
|
||
|
||
if (
|
||
attendance.status ===
|
||
'OPEN'
|
||
) {
|
||
|
||
status =
|
||
'OPEN';
|
||
|
||
label =
|
||
'Právě probíhá';
|
||
|
||
} else if (
|
||
lateMinutes >
|
||
SHIFT_RECONCILIATION.LATE_TOLERANCE_MINUTES
|
||
) {
|
||
|
||
status =
|
||
'LATE';
|
||
|
||
label =
|
||
'Pozdní příchod';
|
||
|
||
} else if (
|
||
earlyLeaveMinutes >
|
||
SHIFT_RECONCILIATION.EARLY_LEAVE_TOLERANCE_MINUTES
|
||
) {
|
||
|
||
status =
|
||
'EARLY_LEAVE';
|
||
|
||
label =
|
||
'Předčasný odchod';
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
const date =
|
||
plan
|
||
? plan.date
|
||
: Utilities.formatDate(
|
||
attendance.start,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
date:
|
||
date,
|
||
|
||
slot_id:
|
||
plan
|
||
? plan.slot_id
|
||
: '',
|
||
|
||
shift_id:
|
||
attendance
|
||
? attendance.shift_id
|
||
: '',
|
||
|
||
planned_start:
|
||
plan
|
||
? plan.start_time
|
||
: '',
|
||
|
||
planned_end:
|
||
plan
|
||
? plan.end_time
|
||
: '',
|
||
|
||
actual_start:
|
||
attendance
|
||
? reconciliationHHMM_(
|
||
attendance.start
|
||
)
|
||
: '',
|
||
|
||
actual_end:
|
||
attendance &&
|
||
attendance.end
|
||
? reconciliationHHMM_(
|
||
attendance.end
|
||
)
|
||
: '',
|
||
|
||
worked_minutes:
|
||
attendance
|
||
? attendance.worked_minutes
|
||
: 0,
|
||
|
||
late_minutes:
|
||
lateMinutes,
|
||
|
||
early_leave_minutes:
|
||
earlyLeaveMinutes,
|
||
|
||
difference_minutes:
|
||
differenceMinutes,
|
||
|
||
status:
|
||
status,
|
||
|
||
status_label:
|
||
label
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
STATISTIKY
|
||
============================================================ */
|
||
|
||
function reconciliationStats_(rows) {
|
||
|
||
const stats =
|
||
{
|
||
|
||
total:
|
||
rows.length,
|
||
|
||
ok:
|
||
0,
|
||
|
||
late:
|
||
0,
|
||
|
||
early_leave:
|
||
0,
|
||
|
||
absent:
|
||
0,
|
||
|
||
extra:
|
||
0,
|
||
|
||
open:
|
||
0,
|
||
|
||
planned:
|
||
0
|
||
|
||
};
|
||
|
||
|
||
rows.forEach(
|
||
function(row) {
|
||
|
||
switch(
|
||
String(
|
||
row.status
|
||
)
|
||
) {
|
||
|
||
case 'OK':
|
||
|
||
stats.ok++;
|
||
break;
|
||
|
||
|
||
case 'LATE':
|
||
|
||
stats.late++;
|
||
break;
|
||
|
||
|
||
case 'EARLY_LEAVE':
|
||
|
||
stats.early_leave++;
|
||
break;
|
||
|
||
|
||
case 'ABSENT':
|
||
|
||
stats.absent++;
|
||
break;
|
||
|
||
|
||
case 'EXTRA':
|
||
|
||
stats.extra++;
|
||
break;
|
||
|
||
|
||
case 'OPEN':
|
||
|
||
stats.open++;
|
||
break;
|
||
|
||
|
||
case 'PLANNED':
|
||
|
||
stats.planned++;
|
||
break;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return stats;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
HELPERS
|
||
============================================================ */
|
||
|
||
function normalizeReconciliationPeriod_(
|
||
period
|
||
) {
|
||
|
||
period =
|
||
String(
|
||
period ||
|
||
''
|
||
).trim();
|
||
|
||
|
||
if (
|
||
!/^\d{4}-\d{2}$/.test(
|
||
period
|
||
)
|
||
) {
|
||
|
||
return Utilities.formatDate(
|
||
new Date(),
|
||
CFG.TZ,
|
||
'yyyy-MM'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return period;
|
||
|
||
}
|
||
|
||
|
||
function reconciliationDateTime_(
|
||
date,
|
||
time
|
||
) {
|
||
|
||
const dateParts =
|
||
String(
|
||
date ||
|
||
''
|
||
).split(
|
||
'-'
|
||
);
|
||
|
||
|
||
const timeParts =
|
||
String(
|
||
time ||
|
||
'00:00'
|
||
).substring(
|
||
0,
|
||
5
|
||
).split(
|
||
':'
|
||
);
|
||
|
||
|
||
if (
|
||
dateParts.length !==
|
||
3 ||
|
||
timeParts.length <
|
||
2
|
||
) {
|
||
|
||
return null;
|
||
|
||
}
|
||
|
||
|
||
const result =
|
||
new Date(
|
||
Number(
|
||
dateParts[0]
|
||
),
|
||
Number(
|
||
dateParts[1]
|
||
) - 1,
|
||
Number(
|
||
dateParts[2]
|
||
),
|
||
Number(
|
||
timeParts[0]
|
||
),
|
||
Number(
|
||
timeParts[1]
|
||
),
|
||
0
|
||
);
|
||
|
||
|
||
return isNaN(
|
||
result.getTime()
|
||
)
|
||
? null
|
||
: result;
|
||
|
||
}
|
||
|
||
|
||
function reconciliationParseDate_(
|
||
value
|
||
) {
|
||
|
||
if (
|
||
!value
|
||
) {
|
||
|
||
return null;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
value instanceof Date
|
||
) {
|
||
|
||
return value;
|
||
|
||
}
|
||
|
||
|
||
const text =
|
||
String(
|
||
value
|
||
).trim();
|
||
|
||
|
||
let match;
|
||
|
||
|
||
match =
|
||
text.match(
|
||
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/
|
||
);
|
||
|
||
|
||
if (
|
||
match
|
||
) {
|
||
|
||
return new Date(
|
||
Number(
|
||
match[1]
|
||
),
|
||
Number(
|
||
match[2]
|
||
) - 1,
|
||
Number(
|
||
match[3]
|
||
),
|
||
Number(
|
||
match[4] ||
|
||
0
|
||
),
|
||
Number(
|
||
match[5] ||
|
||
0
|
||
),
|
||
Number(
|
||
match[6] ||
|
||
0
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
match =
|
||
text.match(
|
||
/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/
|
||
);
|
||
|
||
|
||
if (
|
||
match
|
||
) {
|
||
|
||
return new Date(
|
||
Number(
|
||
match[3]
|
||
),
|
||
Number(
|
||
match[1]
|
||
) - 1,
|
||
Number(
|
||
match[2]
|
||
),
|
||
Number(
|
||
match[4] ||
|
||
0
|
||
),
|
||
Number(
|
||
match[5] ||
|
||
0
|
||
),
|
||
Number(
|
||
match[6] ||
|
||
0
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
match =
|
||
text.match(
|
||
/^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/
|
||
);
|
||
|
||
|
||
if (
|
||
match
|
||
) {
|
||
|
||
return new Date(
|
||
Number(
|
||
match[3]
|
||
),
|
||
Number(
|
||
match[2]
|
||
) - 1,
|
||
Number(
|
||
match[1]
|
||
),
|
||
Number(
|
||
match[4] ||
|
||
0
|
||
),
|
||
Number(
|
||
match[5] ||
|
||
0
|
||
),
|
||
Number(
|
||
match[6] ||
|
||
0
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const date =
|
||
new Date(
|
||
text
|
||
);
|
||
|
||
|
||
return isNaN(
|
||
date.getTime()
|
||
)
|
||
? null
|
||
: date;
|
||
|
||
}
|
||
|
||
|
||
function reconciliationHHMM_(
|
||
date
|
||
) {
|
||
|
||
if (
|
||
!date
|
||
) {
|
||
|
||
return '';
|
||
|
||
}
|
||
|
||
|
||
return Utilities.formatDate(
|
||
date,
|
||
CFG.TZ,
|
||
'HH:mm'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function reconciliationNumber_(
|
||
value
|
||
) {
|
||
|
||
const number =
|
||
Number(
|
||
String(
|
||
value == null
|
||
? 0
|
||
: value
|
||
)
|
||
.replace(
|
||
',',
|
||
'.'
|
||
)
|
||
.replace(
|
||
/\s/g,
|
||
''
|
||
)
|
||
);
|
||
|
||
|
||
return isFinite(
|
||
number
|
||
)
|
||
? number
|
||
: 0;
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
DIAGNOSTIKA
|
||
============================================================ */
|
||
|
||
function testShiftReconciliation() {
|
||
|
||
const period =
|
||
Utilities.formatDate(
|
||
new Date(),
|
||
CFG.TZ,
|
||
'yyyy-MM'
|
||
);
|
||
|
||
|
||
const context =
|
||
buildReconciliationContext_();
|
||
|
||
|
||
Logger.log(
|
||
'Employees: ' +
|
||
context.employees.length
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'Period: ' +
|
||
period
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'Shift reconciliation OK'
|
||
);
|
||
|
||
}
|