From de309704b5aae7fd04c63786f1a63e7a645367ae Mon Sep 17 00:00:00 2001 From: ka-lucas Date: Mon, 15 Sep 2025 15:44:07 -0300 Subject: [PATCH] feat: job ponto --- .env | 45 ++++ env.example | 18 ++ espelho_ponto_final.xlsx | Bin 0 -> 62173 bytes fill_espelho_ponto.py | 489 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 6 + 5 files changed, 558 insertions(+) create mode 100644 .env create mode 100644 env.example create mode 100644 espelho_ponto_final.xlsx create mode 100644 fill_espelho_ponto.py create mode 100644 requirements.txt diff --git a/.env b/.env new file mode 100644 index 0000000..cee2caf --- /dev/null +++ b/.env @@ -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 diff --git a/env.example b/env.example new file mode 100644 index 0000000..35b5912 --- /dev/null +++ b/env.example @@ -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 diff --git a/espelho_ponto_final.xlsx b/espelho_ponto_final.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ea2e0653826971e712cc294aefff52f879e56835 GIT binary patch literal 62173 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 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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e141da5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ + +python-dotenv +pandas +openpyxl +psycopg2-binary +mysql-connector-python