feat: tiap commit jadi entry timesheet terpisah (pro-rata per commit)

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 <noreply@anthropic.com>
This commit is contained in:
sas.fajri
2026-05-29 17:05:36 +07:00
parent c63b65a18d
commit 044d3a5cdc
2 changed files with 88 additions and 95 deletions

View File

@@ -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. Semua parameter opsional jika `.env` sudah diisi.
**Cara hitung pro-rata:** **Cara hitung pro-rata (per commit):**
- Bobot tiap task = span waktu antara commit pertama dan terakhir task tersebut - Setiap commit = satu timesheet entry terpisah
- Task dengan commit pertama di hari itu → span dihitung dari **jam 08:00** - Commit diurutkan secara kronologis
- Single commit mendapat minimum 30 menit - 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 - Total jam selalu = 8
``` ```
Contoh: Contoh:
Task A commit 09:0011:00 → span 08:0011:00 = 180 menit (first commit hari ini) commit 1 10:26 → span 08:00→10:26 = 146 menit (first)
Task B commit 14:0015:30 → span 90 menit commit 2 10:26 → gap 0 → minimum 30 menit
Task C commit 10:00 → span 30 menit (minimum, single commit) commit 3 15:16 → gap 10:26→15:16 = 290 menit
Total span = 300 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 commit 1 = 8 × 146/552 = 2.12h
Task B = 8 × 90/300 = 2.40h commit 2 = 8 × 30/552 = 0.43h
Task C = 8 × 30/300 = 0.80h commit 3 = 8 × 290/552 = 4.20h
commit 4 = 8 × 44/552 = 0.64h
commit 5 = 8 × 42/552 = 0.61h
``` ```
```bash ```bash
@@ -160,14 +166,16 @@ python3 daily_timesheet.py --session-id <id> --user-id 41 --employee-id 37 --aut
**Contoh output preview:** **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 Pramita 52010 08:00→10:26 146m↑ 2.12 Menambahkan regional
Support Kedungdoro 5521 14:0015:30 90m 2.40 fix bug validasi Support Pramita 52010 10:26 30m* 0.43 fix validasi regional
Support Pramita 7788 10:00 (1x) 30m* 0.80 update config 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 TOTAL 552m 8.00
↑ span dihitung dari 08:00 | * single commit → minimum 30 menit ↑ span dari 08:00 | * gap ke commit sebelumnya → minimum 30 menit
``` ```
--- ---

View File

@@ -3,11 +3,13 @@
Ambil semua commit hari ini dari semua repo, distribusikan 8 jam pro-rata, Ambil semua commit hari ini dari semua repo, distribusikan 8 jam pro-rata,
lalu upload ke Odoo timesheet. lalu upload ke Odoo timesheet.
Pro-rata: bobot = time span per task (last commit - first commit). Pro-rata per commit: bobot = gap waktu dari commit sebelumnya (atau dari 08:00 untuk commit pertama).
Single commit mendapat minimum 30 menit. Setiap commit → satu timesheet entry terpisah.
Contoh: Task A span 2 jam + Task B span 1.5 jam + Task C 1 commit → Contoh (commit berurutan):
bobot: 120 + 90 + 30 = 240 menit commit 1 09:00 → span 08:00→09:00 = 60m
Task A = 8 × 120/240 = 4.00h, Task B = 3.00h, Task C = 1.00h 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 import json
@@ -18,7 +20,6 @@ import urllib.request
import urllib.error import urllib.error
import argparse import argparse
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from collections import defaultdict
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path 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 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. Hitung span (menit) per commit, diurutkan secara kronologis.
Jika extend_to_work_start=True, start dihitung dari jam 08:00 - Commit pertama: span dari jam 08:00 ke waktu commit (min 30 menit).
(berlaku untuk task yang punya commit paling awal di hari itu). - Commit berikutnya: gap dari commit sebelumnya (min 30 menit).
""" """
first = min(timestamps) result = []
last = max(timestamps) for i, c in enumerate(sorted_commits):
if i == 0:
if extend_to_work_start: work_start = c["dt"].replace(hour=WORK_START_HOUR, minute=0, second=0, microsecond=0)
start = first.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: else:
start = first span = max(int((c["dt"] - sorted_commits[i - 1]["dt"]).total_seconds() / 60), SINGLE_COMMIT_MINUTES)
result.append(span)
return max( return result
int((last - start).total_seconds() / 60),
SINGLE_COMMIT_MINUTES,
)
def distribute_hours(weights: list[int], total: float = 8.0) -> list[float]: 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.") print("\nTidak ada commit dengan format TASKCODE - deskripsi hari ini.")
return 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") print(f"\nMencari task di Odoo untuk {len(raw_commits)} commit...\n")
task_cache = {} # (code, project_id) → (task_id, task_name) | None task_cache = {} # (code, project_id) → (task_id, task_name, actual_pid) | None
resolved = []
# Group: key = (task_id, project_id)
groups = defaultdict(lambda: {
"descriptions": [], "timestamps": [], "count": 0,
"project_id": None, "project": None, "task_name": None, "date": None,
})
not_found = [] not_found = []
for c in raw_commits: for c in raw_commits:
cache_key = (c["code"], c["project_id"]) cache_key = (c["code"], c["project_id"])
if cache_key not in task_cache: if cache_key not in task_cache:
@@ -341,73 +334,64 @@ def main():
if actual_project_id != c["project_id"]: if actual_project_id != c["project_id"]:
actual_name = next(k for k, v in PROJECT_MAP.items() if v == actual_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}") 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.") print("\nTidak ada task yang ditemukan di Odoo.")
return return
# 3. Hitung pro-rata berdasarkan time span # 3. Sort by time, hitung span per commit
keys = list(groups.keys()) resolved.sort(key=lambda x: x["dt"])
spans = commit_spans(resolved)
# 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
]
hours = distribute_hours(spans, total=float(TOTAL_HOURS)) hours = distribute_hours(spans, total=float(TOTAL_HOURS))
# 4. Tampilkan preview # 4. Tampilkan preview (satu baris per commit)
print(f"\n{'' * 78}") 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}") print(f"{'' * 78}")
entries = [] entries = []
for key, h, span in zip(keys, hours, spans): for i, (c, h, span) in enumerate(zip(resolved, hours, spans)):
g = groups[key] is_first = (i == 0)
ts = sorted(g["timestamps"])
is_first = (key == first_task_key)
if is_first: if is_first:
start_label = f"08:00→{ts[-1].strftime('%H:%M')}" time_label = f"08:00→{c['dt'].strftime('%H:%M')}"
elif len(ts) == 1:
start_label = ts[0].strftime("%H:%M") + " (1x)"
else: 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*" if span == SINGLE_COMMIT_MINUTES:
desc = "; ".join(dict.fromkeys(g["descriptions"])) span_label = f"{'30m↑' if is_first else '30m*'}"
print(f" {g['project']:<20} {str(g['task_id']):<10} {start_label:<15} {span_label:>6} {h:>5.2f} {desc[:28]}") 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({ entries.append({
"odoo": { "odoo": {
"name": desc, "name": c["description"],
"date": g["date"], "date": c["date"],
"unit_amount": h, "unit_amount": h,
"user_id": args.user_id, "user_id": args.user_id,
"employee_id": args.employee_id, "employee_id": args.employee_id,
"task_id": g["task_id"], "task_id": c["task_id"],
"project_id": g["project_id"], "project_id": c["project_id"],
}, },
"display": g, "display": c,
}) })
print(f"{'' * 78}") print(f"{'' * 78}")
total_span = sum(spans) total_span = sum(spans)
print(f" {'TOTAL':<20} {'':<10} {'':<15} {total_span:>5}m {sum(hours):>5.2f}") print(f" {'TOTAL':<20} {'':<10} {'':<10} {total_span:>5}m {sum(hours):>5.2f}")
print(f" ↑ span dihitung dari 08:00 | * single commit → minimum {SINGLE_COMMIT_MINUTES} menit") print(f" ↑ span dari 08:00 | * gap ke commit sebelumnya → minimum {SINGLE_COMMIT_MINUTES} menit")
if not_found: if not_found:
print(f"\n {len(not_found)} commit diabaikan (task tidak ditemukan di Odoo)") print(f"\n {len(not_found)} commit diabaikan (task tidak ditemukan di Odoo)")
@@ -434,8 +418,10 @@ def main():
summary = ", ".join( summary = ", ".join(
f"{e['odoo']['unit_amount']}h {e['display']['project']}" 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})" msg = f"{len(entries)} entry siap ({summary})"
os.system( os.system(
f'osascript -e \'display notification "{msg}" ' f'osascript -e \'display notification "{msg}" '
@@ -458,8 +444,7 @@ def main():
for e in entries: for e in entries:
try: try:
new_id = upload_timesheet(args.session_id, e["odoo"], args.user_id) new_id = upload_timesheet(args.session_id, e["odoo"], args.user_id)
g = e["display"] print(f" OK ID {new_id:<8} {e['display']['project']:<20} {e['odoo']['unit_amount']}h {e['odoo']['name'][:40]}")
print(f" OK ID {new_id:<8} {g['project']:<20} {e['odoo']['unit_amount']}h {e['odoo']['name'][:40]}")
ok += 1 ok += 1
except RuntimeError as ex: except RuntimeError as ex:
print(f" ERR {e['odoo']['name'][:40]}{ex}") print(f" ERR {e['odoo']['name'][:40]}{ex}")