feat: job ponto
This commit is contained in:
parent
43c606f2e5
commit
de309704b5
45
.env
Normal file
45
.env
Normal file
@ -0,0 +1,45 @@
|
||||
|
||||
# Tipo de banco: postgres | mysql
|
||||
DB_TYPE=mysql
|
||||
|
||||
# Credenciais
|
||||
DB_HOST=10.0.0.20
|
||||
DB_PORT=3306
|
||||
DB_NAME=fetchapi
|
||||
DB_USER=fetch
|
||||
DB_PASSWORD=dMvo2KgADsR?JuQm635
|
||||
|
||||
# Intervalo de datas (YYYY-MM-DD)
|
||||
START_DATE=2025-09-01
|
||||
END_DATE=2025-09-30
|
||||
|
||||
# Planilha de saída e aba
|
||||
EXCEL_PATH=espelho_ponto_final.xlsx
|
||||
SHEET_NAME=dados_ponto
|
||||
|
||||
|
||||
# Tabelas
|
||||
TBL_TIME_RECORDS=time_records # ou folha_ponto, se for o seu caso
|
||||
TBL_HOLIDAY=holiday
|
||||
|
||||
# Colunas de time_records
|
||||
COL_TR_ID=id
|
||||
COL_TR_DATE=data
|
||||
COL_TR_USER_ID=user_id
|
||||
COL_TR_IN=hora_entrada
|
||||
COL_TR_OUT=hora_saida
|
||||
COL_TR_INT_IN=hora_entrada_intervalo
|
||||
COL_TR_INT_OUT=hora_retorno_intervalo
|
||||
COL_TR_STATUS=status
|
||||
COL_TR_LOCAL=local
|
||||
COL_TR_HEXTRA=horas_extras
|
||||
COL_TR_TIPO_CALC=tipo_calculo
|
||||
COL_TR_HORAS_NOTURNAS=COL_TR_HORAS_NOTURNAS
|
||||
|
||||
# Janela noturna
|
||||
NIGHT_START_HH=22
|
||||
NIGHT_END_HH=5
|
||||
|
||||
|
||||
COL_HOLI_DATE=date # mude para 'data_feriado' ou 'date' se for o caso
|
||||
COL_HOLI_SVC=service_instance_id
|
||||
18
env.example
Normal file
18
env.example
Normal file
@ -0,0 +1,18 @@
|
||||
|
||||
# Tipo de banco: postgres | mysql
|
||||
DB_TYPE=postgres
|
||||
|
||||
# Credenciais
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=meu_banco
|
||||
DB_USER=meu_usuario
|
||||
DB_PASSWORD=minha_senha
|
||||
|
||||
# Intervalo de datas (YYYY-MM-DD)
|
||||
START_DATE=2025-09-01
|
||||
END_DATE=2025-09-30
|
||||
|
||||
# Planilha de saída e aba
|
||||
EXCEL_PATH=espelho_ponto_final.xlsx
|
||||
SHEET_NAME=dados_ponto
|
||||
BIN
espelho_ponto_final.xlsx
Normal file
BIN
espelho_ponto_final.xlsx
Normal file
Binary file not shown.
489
fill_espelho_ponto.py
Normal file
489
fill_espelho_ponto.py
Normal file
@ -0,0 +1,489 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, date, time as dtime, timedelta
|
||||
from typing import Any, Dict, List, Set
|
||||
from collections import defaultdict
|
||||
|
||||
import pandas as pd
|
||||
import pymysql
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# ----------------- Logging -----------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("db.pipeline")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# ----------------- ENV / Config -----------------
|
||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT = int(os.getenv("DB_PORT", "3306"))
|
||||
DB_USER = os.getenv("DB_USER", "user")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "pass")
|
||||
DB_NAME = os.getenv("DB_NAME", "database")
|
||||
DB_CHARSET = os.getenv("DB_CHARSET", "utf8mb4")
|
||||
|
||||
SERVICE_INSTANCE = os.getenv("SERVICE_INSTANCE", "")
|
||||
|
||||
# Nomes de tabelas (ajuste conforme seu esquema)
|
||||
TBL_HOLIDAY = os.getenv("TBL_HOLIDAY", "holiday")
|
||||
TBL_SHIFT = os.getenv("TBL_SHIFT", "shift")
|
||||
TBL_TIME_RECORDS = os.getenv("TBL_TIME_RECORDS", "time_records")
|
||||
TBL_USER = os.getenv("TBL_USER", "user")
|
||||
|
||||
# Colunas-chave (ajuste conforme seu esquema)
|
||||
COL_HOLI_DATE = os.getenv("COL_HOLI_DATE", "data") # holiday.data (DATE/DATETIME)
|
||||
COL_HOLI_SVC = os.getenv("COL_HOLI_SVC", "service_instance") # holiday.service_instance (opcional)
|
||||
|
||||
COL_SHIFT_ID = os.getenv("COL_SHIFT_ID", "id")
|
||||
COL_SHIFT_SVC = os.getenv("COL_SHIFT_SVC", "service_instance")
|
||||
COL_SHIFT_START = os.getenv("COL_SHIFT_START", "start_time")
|
||||
COL_SHIFT_END = os.getenv("COL_SHIFT_END", "end_time")
|
||||
COL_SHIFT_INT_S = os.getenv("COL_SHIFT_INT_S", "interval_start")
|
||||
COL_SHIFT_INT_E = os.getenv("COL_SHIFT_INT_E", "interval_end")
|
||||
|
||||
COL_TR_ID = os.getenv("COL_TR_ID", "id")
|
||||
COL_TR_DATE = os.getenv("COL_TR_DATE", "data")
|
||||
COL_TR_USER_ID = os.getenv("COL_TR_USER_ID", "user_id")
|
||||
COL_TR_IN = os.getenv("COL_TR_IN", "hora_entrada")
|
||||
COL_TR_OUT = os.getenv("COL_TR_OUT", "hora_saida")
|
||||
COL_TR_INT_IN = os.getenv("COL_TR_INT_IN", "hora_entrada_intervalo")
|
||||
COL_TR_INT_OUT = os.getenv("COL_TR_INT_OUT", "hora_retorno_intervalo")
|
||||
COL_TR_LOCAL = os.getenv("COL_TR_LOCAL", "local")
|
||||
COL_TR_STATUS = os.getenv("COL_TR_STATUS", "status")
|
||||
COL_TR_HEXTRA = os.getenv("COL_TR_HEXTRA", "horas_extras")
|
||||
COL_TR_HNOTURNA = os.getenv("COL_TR_HORAS_NOTURNAS", "horas_noturnas")
|
||||
COL_TR_TIPO_CALC = os.getenv("COL_TR_TIPO_CALC", "tipo_calculo") # pode não existir no seu schema
|
||||
|
||||
COL_USR_ID = os.getenv("COL_USR_ID", "id")
|
||||
COL_USR_SHIFT_ID = os.getenv("COL_USR_SHIFT_ID", "shift_id")
|
||||
COL_USR_SALDO = os.getenv("COL_USR_SALDO", "saldo_atual_horas")
|
||||
COL_USR_SALDO100 = os.getenv("COL_USR_SALDO100", "saldo_atual_horas_100")
|
||||
|
||||
# Janela noturna
|
||||
NIGHT_START_HH = int(os.getenv("NIGHT_START_HH", "18"))
|
||||
NIGHT_END_HH = int(os.getenv("NIGHT_END_HH", "22"))
|
||||
|
||||
# ========= HELPERS PARA TIME =========
|
||||
def _to_time(val) -> dtime | None:
|
||||
"""Converte TIME do MySQL (timedelta/time/str/Timestamp) para datetime.time."""
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, dtime):
|
||||
return val
|
||||
if isinstance(val, timedelta):
|
||||
total = int(val.total_seconds()) % (24*3600)
|
||||
return (datetime.min + timedelta(seconds=total)).time()
|
||||
if isinstance(val, pd.Timestamp):
|
||||
return val.time()
|
||||
if isinstance(val, datetime):
|
||||
return val.time()
|
||||
if isinstance(val, str):
|
||||
for fmt in ("%H:%M:%S", "%H:%M"):
|
||||
try:
|
||||
return datetime.strptime(val, fmt).time()
|
||||
except ValueError:
|
||||
pass
|
||||
return pd.to_datetime(val).time()
|
||||
return pd.to_datetime(val).time()
|
||||
|
||||
def _sec_since_midnight(t: dtime) -> int:
|
||||
return t.hour*3600 + t.minute*60 + t.second
|
||||
|
||||
def _hours_between_times(start_t: dtime, end_t: dtime) -> float:
|
||||
"""Horas entre duas horas no mesmo dia; suporta cruzar meia-noite."""
|
||||
s, e = _sec_since_midnight(start_t), _sec_since_midnight(end_t)
|
||||
if e < s:
|
||||
e += 24*3600 # cruzou meia-noite
|
||||
return (e - s) / 3600.0
|
||||
|
||||
def _overlap_hours(a_start: dtime, a_end: dtime, b_start: dtime, b_end: dtime) -> float:
|
||||
"""Horas de sobreposição; suporta cruzar meia-noite em ambos os intervalos."""
|
||||
def expand(start, end):
|
||||
s, e = _sec_since_midnight(start), _sec_since_midnight(end)
|
||||
if e < s: # cruza meia-noite
|
||||
return [(s, 24*3600), (0, e)]
|
||||
return [(s, e)]
|
||||
A, B = expand(a_start, a_end), expand(b_start, b_end)
|
||||
overlap = 0
|
||||
for s1, e1 in A:
|
||||
for s2, e2 in B:
|
||||
overlap += max(0, min(e1, e2) - max(s1, s2))
|
||||
return overlap / 3600.0
|
||||
|
||||
def _to_iso_time(val) -> str | None:
|
||||
"""Formata para 'HH:MM:SS'."""
|
||||
if val is None:
|
||||
return None
|
||||
t = _to_time(val)
|
||||
return t.isoformat() if t else None
|
||||
|
||||
# ========= DB =========
|
||||
def get_conn():
|
||||
return pymysql.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
database=DB_NAME,
|
||||
charset=DB_CHARSET,
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=False,
|
||||
connect_timeout=15,
|
||||
read_timeout=30,
|
||||
write_timeout=30,
|
||||
)
|
||||
|
||||
def _table_columns(conn, table: str) -> Set[str]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DESCRIBE `{table}`;")
|
||||
cols = {row["Field"] for row in cur.fetchall()}
|
||||
return cols
|
||||
|
||||
def fetch_feriados(conn) -> Set[date]:
|
||||
"""Retorna set de datas de feriados. Se existir coluna de service_instance, filtra por ela."""
|
||||
cols = _table_columns(conn, TBL_HOLIDAY)
|
||||
params = []
|
||||
where = ""
|
||||
if COL_HOLI_SVC in cols and SERVICE_INSTANCE:
|
||||
where = f"WHERE `{COL_HOLI_SVC}`=%s"
|
||||
params = [SERVICE_INSTANCE]
|
||||
sql = f"SELECT `{COL_HOLI_DATE}` AS data FROM `{TBL_HOLIDAY}` {where};"
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
feriados = set()
|
||||
for r in rows:
|
||||
if r["data"] is None:
|
||||
continue
|
||||
feriados.add(pd.to_datetime(r["data"]).date())
|
||||
logger.info(f"Feriados carregados: {len(feriados)}")
|
||||
return feriados
|
||||
|
||||
def fetch_shifts(conn) -> Dict[int, Dict[str, Any]]:
|
||||
"""Busca escalas, converte horários e calcula horas previstas. Retorna dict por id."""
|
||||
cols = _table_columns(conn, TBL_SHIFT)
|
||||
params = []
|
||||
where = ""
|
||||
if COL_SHIFT_SVC in cols and SERVICE_INSTANCE:
|
||||
where = f"WHERE `{COL_SHIFT_SVC}`=%s"
|
||||
params = [SERVICE_INSTANCE]
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
`{COL_SHIFT_ID}` AS id,
|
||||
`{COL_SHIFT_START}` AS start_time,
|
||||
`{COL_SHIFT_END}` AS end_time,
|
||||
`{COL_SHIFT_INT_S}` AS interval_start,
|
||||
`{COL_SHIFT_INT_E}` AS interval_end
|
||||
FROM `{TBL_SHIFT}`
|
||||
{where};
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
shifts = {}
|
||||
for s in rows:
|
||||
st = _to_time(s.get("start_time"))
|
||||
en = _to_time(s.get("end_time"))
|
||||
is_ = _to_time(s.get("interval_start"))
|
||||
ie_ = _to_time(s.get("interval_end"))
|
||||
|
||||
dur_int_h = _hours_between_times(is_, ie_) if (is_ and ie_) else 0.0
|
||||
work_h = _hours_between_times(st, en)
|
||||
horas_previstas = work_h - dur_int_h # float (horas)
|
||||
|
||||
s["start_time"] = st
|
||||
s["end_time"] = en
|
||||
s["interval_start"] = is_
|
||||
s["interval_end"] = ie_
|
||||
s["duracao_intervalo"] = timedelta(hours=dur_int_h)
|
||||
s["horas_trabalhadas_previstas"] = horas_previstas
|
||||
|
||||
shifts[int(s["id"])] = s
|
||||
|
||||
logger.info(f"Escalas carregadas: {len(shifts)}")
|
||||
return shifts
|
||||
|
||||
# cache simples do schema
|
||||
_SCHEMA_CACHE = {}
|
||||
|
||||
def get_table_schema(conn, table: str) -> Dict[str, str]:
|
||||
"""Retorna {coluna: tipo} em minúsculas, ex.: {'horas_extras': 'decimal(6,2)'}."""
|
||||
global _SCHEMA_CACHE
|
||||
if table in _SCHEMA_CACHE:
|
||||
return _SCHEMA_CACHE[table]
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SHOW COLUMNS FROM `{table}`;")
|
||||
schema = {row["Field"]: row["Type"].lower() for row in cur.fetchall()}
|
||||
_SCHEMA_CACHE[table] = schema
|
||||
return schema
|
||||
|
||||
def _hours_to_hhmmss(hours: float | int | None) -> str | None:
|
||||
"""Converte horas (float) em 'HH:MM:SS'. Suporta negativos."""
|
||||
if hours is None:
|
||||
return None
|
||||
hours = float(hours)
|
||||
total_seconds = int(round(hours * 3600))
|
||||
sign = "-" if total_seconds < 0 else ""
|
||||
total_seconds = abs(total_seconds)
|
||||
h = total_seconds // 3600
|
||||
m = (total_seconds % 3600) // 60
|
||||
s = total_seconds % 60
|
||||
return f"{sign}{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
def _fit_value_for_column(coltype: str, value):
|
||||
"""Adapta 'value' (horas float) para o tipo da coluna do MySQL."""
|
||||
if value is None:
|
||||
return None
|
||||
t = (coltype or "").lower()
|
||||
# TIME -> HH:MM:SS
|
||||
if t.startswith("time"):
|
||||
return _hours_to_hhmmss(value)
|
||||
# DECIMAL/DOUBLE/FLOAT -> arredonda
|
||||
if t.startswith("decimal") or t.startswith("double") or t.startswith("float"):
|
||||
return round(float(value), 2)
|
||||
# INT -> segundos inteiros
|
||||
if t.startswith("int"):
|
||||
return int(round(float(value) * 3600))
|
||||
# fallback: manda como está
|
||||
return value
|
||||
|
||||
def fetch_time_records_range(conn) -> List[Dict[str, Any]]:
|
||||
|
||||
start_str = os.getenv("START_DATE")
|
||||
end_str = os.getenv("END_DATE")
|
||||
if not start_str or not end_str:
|
||||
raise ValueError("Defina START_DATE e END_DATE no .env (YYYY-MM-DD).")
|
||||
|
||||
# janela: [start 00:00:00, end+1 00:00:00)
|
||||
start_dt = datetime.strptime(start_str, "%Y-%m-%d")
|
||||
end_dt = datetime.strptime(end_str, "%Y-%m-%d") + timedelta(days=1)
|
||||
|
||||
sql = f"""
|
||||
SELECT *
|
||||
FROM `{TBL_TIME_RECORDS}`
|
||||
WHERE `{COL_TR_DATE}` >= %s
|
||||
AND `{COL_TR_DATE}` < %s
|
||||
ORDER BY `{COL_TR_DATE}`, `{COL_TR_USER_ID}`, `{COL_TR_ID}`;
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (start_dt, end_dt))
|
||||
rows = cur.fetchall()
|
||||
|
||||
logger.info(f"Registros no intervalo [{start_str} .. {end_str}]: {len(rows)} (sem filtro de service_instance)")
|
||||
return rows
|
||||
|
||||
|
||||
def fetch_user(conn, user_id: int) -> Dict[str, Any]:
|
||||
sql = f"SELECT * FROM `{TBL_USER}` WHERE `{COL_USR_ID}`=%s;"
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (user_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Usuário {user_id} não encontrado.")
|
||||
return row
|
||||
|
||||
def update_time_record(conn, record_id: int, payload: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Atualiza o time_record. Converte horas_extras / horas_noturnas
|
||||
para o tipo real da coluna (TIME/DECIMAL/INT etc.) e loga o rowcount.
|
||||
"""
|
||||
cols = _table_columns(conn, TBL_TIME_RECORDS)
|
||||
schema = get_table_schema(conn, TBL_TIME_RECORDS)
|
||||
|
||||
# Campos base
|
||||
candidates = [
|
||||
(COL_TR_IN, payload.get("hora_entrada")), # já vai 'HH:MM:SS'
|
||||
(COL_TR_OUT, payload.get("hora_saida")),
|
||||
(COL_TR_INT_IN, payload.get("hora_entrada_intervalo")),
|
||||
(COL_TR_INT_OUT, payload.get("hora_retorno_intervalo")),
|
||||
(COL_TR_STATUS, payload.get("status")),
|
||||
(COL_TR_LOCAL, payload.get("local")),
|
||||
]
|
||||
|
||||
# horas_extras (converter se existir)
|
||||
if COL_TR_HEXTRA in cols:
|
||||
he_val = payload.get("horas_extras")
|
||||
he_val = _fit_value_for_column(schema.get(COL_TR_HEXTRA, ""), he_val)
|
||||
candidates.append((COL_TR_HEXTRA, he_val))
|
||||
else:
|
||||
logger.warning(f"Coluna '{COL_TR_HEXTRA}' não existe em `{TBL_TIME_RECORDS}`; não será atualizada.")
|
||||
|
||||
# tipo_calculo (opcional)
|
||||
if COL_TR_TIPO_CALC in cols:
|
||||
candidates.append((COL_TR_TIPO_CALC, payload.get("tipo_calculo")))
|
||||
|
||||
# horas_noturnas (converter se existir)
|
||||
col_hnot = os.getenv("COL_TR_HNOTURNA", "horas_noturnas")
|
||||
if col_hnot in cols:
|
||||
hn_val = payload.get("horas_noturnas")
|
||||
hn_val = _fit_value_for_column(schema.get(col_hnot, ""), hn_val)
|
||||
candidates.append((col_hnot, hn_val))
|
||||
else:
|
||||
logger.warning(f"Coluna '{col_hnot}' não existe em `{TBL_TIME_RECORDS}`; não será atualizada.")
|
||||
|
||||
# Mantém apenas colunas existentes
|
||||
fields = [(c, v) for (c, v) in candidates if c in cols]
|
||||
if not fields:
|
||||
logger.error(
|
||||
f"Nenhum campo válido para atualizar em `{TBL_TIME_RECORDS}` (id={record_id}). "
|
||||
f"Verifique nomes no .env. Colunas existentes: {sorted(cols)}"
|
||||
)
|
||||
return
|
||||
|
||||
set_clause = ", ".join([f"`{k}`=%s" for k, _ in fields])
|
||||
params = [v for _, v in fields] + [record_id]
|
||||
sql = f"UPDATE `{TBL_TIME_RECORDS}` SET {set_clause} WHERE `{COL_TR_ID}`=%s;"
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rc = cur.rowcount
|
||||
|
||||
enviados = ", ".join([f"{k}={repr(v)}" for k, v in fields])
|
||||
logger.info(f"UPDATE {TBL_TIME_RECORDS} WHERE {COL_TR_ID}={record_id} "
|
||||
f"→ ({enviados}) | linhas_afetadas={rc}")
|
||||
if rc == 0:
|
||||
logger.warning(
|
||||
f"UPDATE não alterou linhas (id={record_id}). Motivos comuns: "
|
||||
f"PK/WHERE não bate, ou valores já eram iguais, ou triggers revertendo."
|
||||
)
|
||||
|
||||
|
||||
def update_user(conn, user_id: int, user_updates: Dict[str, Any]) -> None:
|
||||
"""Atualiza colunas de saldo do usuário (somente as que existem na tabela)."""
|
||||
cols = _table_columns(conn, TBL_USER)
|
||||
|
||||
fields = []
|
||||
params = []
|
||||
|
||||
if user_updates.get("saldo_horas") is not None and COL_USR_SALDO in cols:
|
||||
fields.append(f"`{COL_USR_SALDO}`=%s")
|
||||
params.append(user_updates["saldo_horas"])
|
||||
|
||||
if user_updates.get("saldo_atual_horas_100") is not None and COL_USR_SALDO100 in cols:
|
||||
fields.append(f"`{COL_USR_SALDO100}`=%s")
|
||||
params.append(user_updates["saldo_atual_horas_100"])
|
||||
|
||||
if user_updates.get("saldo_atual_horas") is not None and COL_USR_SALDO in cols:
|
||||
fields.append(f"`{COL_USR_SALDO}`=%s")
|
||||
params.append(user_updates["saldo_atual_horas"])
|
||||
|
||||
if not fields:
|
||||
logger.warning(f"Nenhum campo válido para atualizar em `{TBL_USER}` para user_id={user_id}. Colunas: {sorted(cols)}")
|
||||
return
|
||||
|
||||
params.append(user_id)
|
||||
sql = f"UPDATE `{TBL_USER}` SET {', '.join(fields)} WHERE `{COL_USR_ID}`=%s;"
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
|
||||
|
||||
def processar_registros_db() -> None:
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
feriados = fetch_feriados(conn)
|
||||
shifts = fetch_shifts(conn)
|
||||
registros = fetch_time_records_range(conn) # usa START_DATE/END_DATE do .env
|
||||
|
||||
# acumula horas extras positivas do período por usuário
|
||||
extras_periodo = defaultdict(float)
|
||||
user_cache: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
for tr in registros:
|
||||
try:
|
||||
# --- data do registro
|
||||
data_reg = pd.to_datetime(tr.get(COL_TR_DATE)).date()
|
||||
|
||||
# --- horários do registro
|
||||
h_in = _to_time(tr.get(COL_TR_IN))
|
||||
h_out = _to_time(tr.get(COL_TR_OUT))
|
||||
h_i_in = _to_time(tr.get(COL_TR_INT_IN))
|
||||
h_i_out = _to_time(tr.get(COL_TR_INT_OUT))
|
||||
|
||||
if not all([h_in, h_out, h_i_in, h_i_out]):
|
||||
logger.warning(f"Registro incompleto (ignorado) id={tr.get(COL_TR_ID)} user={tr.get(COL_TR_USER_ID)}")
|
||||
continue
|
||||
|
||||
# --- horas trabalhadas do dia
|
||||
dur_intervalo_h = _hours_between_times(h_i_in, h_i_out)
|
||||
work_h = _hours_between_times(h_in, h_out)
|
||||
horas_trab = work_h - dur_intervalo_h
|
||||
|
||||
# --- escala do usuário
|
||||
user_id = int(tr.get(COL_TR_USER_ID))
|
||||
user = user_cache.get(user_id) or fetch_user(conn, user_id)
|
||||
user_cache[user_id] = user
|
||||
|
||||
shift_id = user.get(COL_USR_SHIFT_ID)
|
||||
if not shift_id or int(shift_id) not in shifts:
|
||||
logger.warning(f"Escala não encontrada (user_id={user_id}, shift_id={shift_id})")
|
||||
continue
|
||||
|
||||
esc = shifts[int(shift_id)]
|
||||
horas_previstas = esc.get("horas_trabalhadas_previstas")
|
||||
if horas_previstas is None:
|
||||
st = esc.get("start_time"); en = esc.get("end_time")
|
||||
is_ = esc.get("interval_start"); ie_ = esc.get("interval_end")
|
||||
work_prev = _hours_between_times(st, en)
|
||||
dur_prev = _hours_between_times(is_, ie_) if (is_ and ie_) else 0.0
|
||||
horas_previstas = work_prev - dur_prev
|
||||
|
||||
# --- HORA EXTRA DIÁRIA (positivo = trabalhou a mais)
|
||||
extras = round(horas_trab - horas_previstas, 2)
|
||||
extras_pos = max(0.0, extras) # só grava positivo na coluna horas_extras
|
||||
|
||||
# --- HORAS NOTURNAS (22->05 por padrão, configure NIGHT_START_HH/NIGHT_END_HH no .env)
|
||||
n_start = dtime(NIGHT_START_HH, 0)
|
||||
n_end = dtime(NIGHT_END_HH, 0)
|
||||
horas_noturnas = round(_overlap_hours(h_in, h_out, n_start, n_end), 2)
|
||||
|
||||
# --- UPDATE time_records (grava a diária)
|
||||
payload = {
|
||||
"hora_entrada": _to_iso_time(h_in),
|
||||
"hora_saida": _to_iso_time(h_out),
|
||||
"hora_entrada_intervalo": _to_iso_time(h_i_in),
|
||||
"hora_retorno_intervalo": _to_iso_time(h_i_out),
|
||||
"horas_extras": extras_pos, # diária (sempre >= 0)
|
||||
"horas_noturnas": horas_noturnas, # diária
|
||||
"status": tr.get(COL_TR_STATUS),
|
||||
"local": tr.get(COL_TR_LOCAL),
|
||||
# tipo_calculo é opcional; deixe None se não quiser marcar:
|
||||
"tipo_calculo": ("Feriado" if (extras_pos > 0 and data_reg in feriados) else
|
||||
"Horas Extras" if extras_pos > 0 else None),
|
||||
}
|
||||
update_time_record(conn, int(tr.get(COL_TR_ID)), payload)
|
||||
|
||||
# --- acumula extras do período p/ este usuário
|
||||
if extras_pos > 0:
|
||||
extras_periodo[user_id] += extras_pos
|
||||
|
||||
except Exception as e_inner:
|
||||
logger.error(f"Erro ao processar registro id={tr.get(COL_TR_ID)}: {e_inner}")
|
||||
|
||||
# --- PÓS-LOOP: adiciona a soma do período ao saldo acumulado do usuário
|
||||
for uid, total_extras in extras_periodo.items():
|
||||
try:
|
||||
u = user_cache.get(uid) or fetch_user(conn, uid)
|
||||
base = u.get(COL_USR_SALDO, 0) or 0.0
|
||||
novo = round(float(base) + float(total_extras), 2)
|
||||
|
||||
update_user(conn, uid, {"saldo_atual_horas": novo})
|
||||
logger.info(f"[Aggregate] user={uid} {COL_USR_SALDO}: {base} + {total_extras:.2f} = {novo:.2f}")
|
||||
except Exception as agg_err:
|
||||
logger.error(f"Falha ao agregar extras do período para user={uid}: {agg_err}")
|
||||
|
||||
conn.commit()
|
||||
logger.info("✅ Processamento finalizado com sucesso.")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f"Erro geral no processamento: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
processar_registros_db()
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
|
||||
python-dotenv
|
||||
pandas
|
||||
openpyxl
|
||||
psycopg2-binary
|
||||
mysql-connector-python
|
||||
Loading…
Reference in New Issue
Block a user