From 044d3a5cdc7a295bd36d43082465f4d1c43057dc Mon Sep 17 00:00:00 2001 From: "sas.fajri" Date: Fri, 29 May 2026 17:05:36 +0700 Subject: [PATCH] feat: tiap commit jadi entry timesheet terpisah (pro-rata per commit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sebelumnya commit dengan kode task yang sama digabung jadi 1 entry. Sekarang setiap commit → 1 entry Odoo. Bobot waktu dihitung dari gap ke commit sebelumnya (atau dari 08:00 untuk commit pertama hari itu). Co-Authored-By: Claude Sonnet 4.6 --- README.md | 42 ++++++++------ daily_timesheet.py | 141 ++++++++++++++++++++------------------------- 2 files changed, 88 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index d743c33..828865e 100644 --- a/README.md +++ b/README.md @@ -111,22 +111,28 @@ Script utama. Ambil commit hari ini, cari task di Odoo, hitung pro-rata, simpan Semua parameter opsional jika `.env` sudah diisi. -**Cara hitung pro-rata:** -- Bobot tiap task = span waktu antara commit pertama dan terakhir task tersebut -- Task dengan commit pertama di hari itu → span dihitung dari **jam 08:00** -- Single commit mendapat minimum 30 menit +**Cara hitung pro-rata (per commit):** +- Setiap commit = satu timesheet entry terpisah +- Commit diurutkan secara kronologis +- Bobot commit pertama = waktu dari **jam 08:00** ke commit tersebut +- Bobot commit berikutnya = gap dari commit sebelumnya +- Gap 0 menit (commit bersamaan) mendapat minimum 30 menit - Total jam selalu = 8 ``` Contoh: - Task A commit 09:00–11:00 → span 08:00–11:00 = 180 menit (first commit hari ini) - Task B commit 14:00–15:30 → span 90 menit - Task C commit 10:00 → span 30 menit (minimum, single commit) - Total span = 300 menit + commit 1 10:26 → span 08:00→10:26 = 146 menit (first) + commit 2 10:26 → gap 0 → minimum 30 menit + commit 3 15:16 → gap 10:26→15:16 = 290 menit + commit 4 16:00 → gap 44 menit + commit 5 16:42 → gap 42 menit + Total span = 552 menit - Task A = 8 × 180/300 = 4.80h - Task B = 8 × 90/300 = 2.40h - Task C = 8 × 30/300 = 0.80h + commit 1 = 8 × 146/552 = 2.12h + commit 2 = 8 × 30/552 = 0.43h + commit 3 = 8 × 290/552 = 4.20h + commit 4 = 8 × 44/552 = 0.64h + commit 5 = 8 × 42/552 = 0.61h ``` ```bash @@ -160,14 +166,16 @@ python3 daily_timesheet.py --session-id --user-id 41 --employee-id 37 --aut **Contoh output preview:** ``` ────────────────────────────────────────────────────────────────────────────── - PROJECT TASK WAKTU SPAN JAM DESKRIPSI + PROJECT TASK WAKTU SPAN JAM DESKRIPSI ────────────────────────────────────────────────────────────────────────────── - CPONE 10832 08:00→11:00 180m↑ 4.80 buat api endpoint - Support Kedungdoro 5521 14:00–15:30 90m 2.40 fix bug validasi - Support Pramita 7788 10:00 (1x) 30m* 0.80 update config + Support Pramita 52010 08:00→10:26 146m↑ 2.12 Menambahkan regional + Support Pramita 52010 10:26 30m* 0.43 fix validasi regional + IBL 52016 15:16 290m 4.20 update deploy.sh + IBL 52016 16:00 44m 0.64 fix deploy path + IBL 52016 16:42 42m 0.61 update script prod ────────────────────────────────────────────────────────────────────────────── - TOTAL 300m 8.00 - ↑ span dihitung dari 08:00 | * single commit → minimum 30 menit + TOTAL 552m 8.00 + ↑ span dari 08:00 | * gap ke commit sebelumnya → minimum 30 menit ``` --- diff --git a/daily_timesheet.py b/daily_timesheet.py index 8091bba..3373efb 100755 --- a/daily_timesheet.py +++ b/daily_timesheet.py @@ -3,11 +3,13 @@ Ambil semua commit hari ini dari semua repo, distribusikan 8 jam pro-rata, lalu upload ke Odoo timesheet. -Pro-rata: bobot = time span per task (last commit - first commit). -Single commit mendapat minimum 30 menit. -Contoh: Task A span 2 jam + Task B span 1.5 jam + Task C 1 commit → - bobot: 120 + 90 + 30 = 240 menit - Task A = 8 × 120/240 = 4.00h, Task B = 3.00h, Task C = 1.00h +Pro-rata per commit: bobot = gap waktu dari commit sebelumnya (atau dari 08:00 untuk commit pertama). +Setiap commit → satu timesheet entry terpisah. +Contoh (commit berurutan): + commit 1 09:00 → span 08:00→09:00 = 60m + commit 2 10:30 → span 09:00→10:30 = 90m + commit 3 10:30 → gap 0 → minimum 30m + Total = 180m → commit 1 = 8×60/180=2.67h, commit 2 = 4.00h, commit 3 = 1.33h """ import json @@ -18,7 +20,6 @@ import urllib.request import urllib.error import argparse from datetime import date, datetime, timedelta -from collections import defaultdict from decimal import Decimal, ROUND_HALF_UP from pathlib import Path @@ -226,24 +227,21 @@ SINGLE_COMMIT_MINUTES = 30 # bobot minimum untuk task dengan 1 commit WORK_START_HOUR = 8 # jam mulai kerja -def span_minutes(timestamps: list[datetime], extend_to_work_start: bool = False) -> int: +def commit_spans(sorted_commits: list[dict]) -> list[int]: """ - Menit dari start sampai commit terakhir. Minimum 30 menit. - Jika extend_to_work_start=True, start dihitung dari jam 08:00 - (berlaku untuk task yang punya commit paling awal di hari itu). + Hitung span (menit) per commit, diurutkan secara kronologis. + - Commit pertama: span dari jam 08:00 ke waktu commit (min 30 menit). + - Commit berikutnya: gap dari commit sebelumnya (min 30 menit). """ - first = min(timestamps) - last = max(timestamps) - - if extend_to_work_start: - start = first.replace(hour=WORK_START_HOUR, minute=0, second=0, microsecond=0) - else: - start = first - - return max( - int((last - start).total_seconds() / 60), - SINGLE_COMMIT_MINUTES, - ) + result = [] + for i, c in enumerate(sorted_commits): + if i == 0: + work_start = c["dt"].replace(hour=WORK_START_HOUR, minute=0, second=0, microsecond=0) + span = max(int((c["dt"] - work_start).total_seconds() / 60), SINGLE_COMMIT_MINUTES) + else: + span = max(int((c["dt"] - sorted_commits[i - 1]["dt"]).total_seconds() / 60), SINGLE_COMMIT_MINUTES) + result.append(span) + return result def distribute_hours(weights: list[int], total: float = 8.0) -> list[float]: @@ -314,18 +312,13 @@ def main(): print("\nTidak ada commit dengan format TASKCODE - deskripsi hari ini.") return - # 2. Cari task_id di Odoo, group by (task_id, project_id) + # 2. Cari task_id di Odoo untuk setiap commit print(f"\nMencari task di Odoo untuk {len(raw_commits)} commit...\n") - task_cache = {} # (code, project_id) → (task_id, task_name) | None - - # Group: key = (task_id, project_id) - groups = defaultdict(lambda: { - "descriptions": [], "timestamps": [], "count": 0, - "project_id": None, "project": None, "task_name": None, "date": None, - }) - + task_cache = {} # (code, project_id) → (task_id, task_name, actual_pid) | None + resolved = [] not_found = [] + for c in raw_commits: cache_key = (c["code"], c["project_id"]) if cache_key not in task_cache: @@ -341,73 +334,64 @@ def main(): if actual_project_id != c["project_id"]: actual_name = next(k for k, v in PROJECT_MAP.items() if v == actual_project_id) print(f" FALLBACK [{c['code']}] tidak ada di {c['project']}, ditemukan di {actual_name}") - key = (task_id, actual_project_id) - groups[key]["descriptions"].append(c["description"]) - groups[key]["timestamps"].append(c["dt"]) - groups[key]["count"] += 1 - groups[key]["project_id"] = actual_project_id - groups[key]["project"] = next(k for k, v in PROJECT_MAP.items() if v == actual_project_id) - groups[key]["task_id"] = task_id - groups[key]["task_name"] = task_name - groups[key]["date"] = c["date"] - if not groups: + resolved.append({ + "task_id": task_id, + "task_name": task_name, + "project_id": actual_project_id, + "project": next(k for k, v in PROJECT_MAP.items() if v == actual_project_id), + "description": c["description"], + "date": c["date"], + "time": c["time"], + "dt": c["dt"], + "hash": c["hash"], + }) + + if not resolved: print("\nTidak ada task yang ditemukan di Odoo.") return - # 3. Hitung pro-rata berdasarkan time span - keys = list(groups.keys()) - - # Task yang punya commit paling awal di hari itu → spannya dihitung dari 08:00 - global_first_dt = min(min(groups[k]["timestamps"]) for k in keys) - first_task_key = next( - k for k in keys if global_first_dt in groups[k]["timestamps"] - ) - - spans = [ - span_minutes(groups[k]["timestamps"], extend_to_work_start=(k == first_task_key)) - for k in keys - ] + # 3. Sort by time, hitung span per commit + resolved.sort(key=lambda x: x["dt"]) + spans = commit_spans(resolved) hours = distribute_hours(spans, total=float(TOTAL_HOURS)) - # 4. Tampilkan preview + # 4. Tampilkan preview (satu baris per commit) print(f"\n{'─' * 78}") - print(f" {'PROJECT':<20} {'TASK':<10} {'WAKTU':<15} {'SPAN':>6} {'JAM':>5} DESKRIPSI") + print(f" {'PROJECT':<20} {'TASK':<10} {'WAKTU':<10} {'SPAN':>6} {'JAM':>5} DESKRIPSI") print(f"{'─' * 78}") entries = [] - for key, h, span in zip(keys, hours, spans): - g = groups[key] - ts = sorted(g["timestamps"]) - is_first = (key == first_task_key) - + for i, (c, h, span) in enumerate(zip(resolved, hours, spans)): + is_first = (i == 0) if is_first: - start_label = f"08:00→{ts[-1].strftime('%H:%M')}" - elif len(ts) == 1: - start_label = ts[0].strftime("%H:%M") + " (1x)" + time_label = f"08:00→{c['dt'].strftime('%H:%M')}" else: - start_label = f"{ts[0].strftime('%H:%M')}–{ts[-1].strftime('%H:%M')}" + time_label = c["dt"].strftime("%H:%M") - span_label = f"{span}m{'↑' if is_first else ''}" if span > SINGLE_COMMIT_MINUTES else "30m*" - desc = "; ".join(dict.fromkeys(g["descriptions"])) - print(f" {g['project']:<20} {str(g['task_id']):<10} {start_label:<15} {span_label:>6} {h:>5.2f} {desc[:28]}") + if span == SINGLE_COMMIT_MINUTES: + span_label = f"{'30m↑' if is_first else '30m*'}" + else: + span_label = f"{span}m{'↑' if is_first else ''}" + + print(f" {c['project']:<20} {str(c['task_id']):<10} {time_label:<10} {span_label:>6} {h:>5.2f} {c['description'][:28]}") entries.append({ "odoo": { - "name": desc, - "date": g["date"], + "name": c["description"], + "date": c["date"], "unit_amount": h, "user_id": args.user_id, "employee_id": args.employee_id, - "task_id": g["task_id"], - "project_id": g["project_id"], + "task_id": c["task_id"], + "project_id": c["project_id"], }, - "display": g, + "display": c, }) print(f"{'─' * 78}") total_span = sum(spans) - print(f" {'TOTAL':<20} {'':<10} {'':<15} {total_span:>5}m {sum(hours):>5.2f}") - print(f" ↑ span dihitung dari 08:00 | * single commit → minimum {SINGLE_COMMIT_MINUTES} menit") + print(f" {'TOTAL':<20} {'':<10} {'':<10} {total_span:>5}m {sum(hours):>5.2f}") + print(f" ↑ span dari 08:00 | * gap ke commit sebelumnya → minimum {SINGLE_COMMIT_MINUTES} menit") if not_found: print(f"\n {len(not_found)} commit diabaikan (task tidak ditemukan di Odoo)") @@ -434,8 +418,10 @@ def main(): summary = ", ".join( f"{e['odoo']['unit_amount']}h {e['display']['project']}" - for e in entries + for e in entries[:3] ) + if len(entries) > 3: + summary += f" +{len(entries) - 3} lagi" msg = f"{len(entries)} entry siap ({summary})" os.system( f'osascript -e \'display notification "{msg}" ' @@ -458,8 +444,7 @@ def main(): for e in entries: try: new_id = upload_timesheet(args.session_id, e["odoo"], args.user_id) - g = e["display"] - print(f" OK ID {new_id:<8} {g['project']:<20} {e['odoo']['unit_amount']}h {e['odoo']['name'][:40]}") + print(f" OK ID {new_id:<8} {e['display']['project']:<20} {e['odoo']['unit_amount']}h {e['odoo']['name'][:40]}") ok += 1 except RuntimeError as ex: print(f" ERR {e['odoo']['name'][:40]} → {ex}")