feat: pro-rata berdasarkan time span commit bukan jumlah commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sas.fajri
2026-05-28 10:22:37 +07:00
parent c9bf004a4f
commit d2e9641739

View File

@@ -3,8 +3,11 @@
Ambil semua commit hari ini dari semua repo, distribusikan 8 jam pro-rata,
lalu upload ke Odoo timesheet.
Pro-rata: bobot = jumlah commit per task. Total = 8 jam.
Contoh: Task A 3 commit + Task B 1 commit → A=6h, B=2h.
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
"""
import json
@@ -13,7 +16,7 @@ import subprocess
import urllib.request
import urllib.error
import argparse
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from collections import defaultdict
from decimal import Decimal, ROUND_HALF_UP
@@ -153,7 +156,7 @@ def get_commits_today(repo_path: str, author: str, today: str) -> list[dict]:
f"--after={today}",
f"--before={until}",
"--format=%h|%ad|%s",
"--date=short",
"--date=format:%Y-%m-%d %H:%M",
]
try:
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True)
@@ -165,17 +168,34 @@ def get_commits_today(repo_path: str, author: str, today: str) -> list[dict]:
continue
parts = line.split("|", 2)
if len(parts) == 3:
commits.append({"hash": parts[0], "date": parts[1], "message": parts[2]})
dt = datetime.strptime(parts[1], "%Y-%m-%d %H:%M")
commits.append({
"hash": parts[0],
"date": dt.strftime("%Y-%m-%d"),
"time": dt.strftime("%H:%M"),
"dt": dt,
"message": parts[2],
})
return commits
# ── Pro-rata ──────────────────────────────────────────────────────────────────
SINGLE_COMMIT_MINUTES = 30 # bobot minimum untuk task dengan 1 commit
def span_minutes(timestamps: list[datetime]) -> int:
"""Menit antara commit pertama dan terakhir. Minimum 30 menit."""
if len(timestamps) <= 1:
return SINGLE_COMMIT_MINUTES
return max(
int((max(timestamps) - min(timestamps)).total_seconds() / 60),
SINGLE_COMMIT_MINUTES,
)
def distribute_hours(weights: list[int], total: float = 8.0) -> list[float]:
"""
Distribusi jam pro-rata berdasarkan bobot (jumlah commit).
Adjustment di entry terakhir supaya sum persis = total.
"""
"""Pro-rata berdasarkan bobot (menit span). Sum = total. Adjustment di entry terakhir."""
total_weight = sum(weights)
raw = [Decimal(str(total)) * Decimal(w) / Decimal(total_weight) for w in weights]
rounded = [float(r.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) for r in raw]
@@ -221,6 +241,8 @@ def main():
"code": m.group(1),
"description": m.group(2),
"date": c["date"],
"time": c["time"],
"dt": c["dt"],
"hash": c["hash"],
"repo": repo_name,
"project": repo["project"],
@@ -238,9 +260,11 @@ def main():
task_cache = {} # (code, project_id) → (task_id, task_name) | None
# Group: key = (task_id, project_id), value = list of commit descriptions & count
groups = defaultdict(lambda: {"descriptions": [], "count": 0, "project_id": None,
"project": None, "task_name": None, "date": None})
# 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 = []
for c in raw_commits:
@@ -257,6 +281,7 @@ def main():
task_id, task_name = task
key = (task_id, c["project_id"])
groups[key]["descriptions"].append(c["description"])
groups[key]["timestamps"].append(c["dt"])
groups[key]["count"] += 1
groups[key]["project_id"] = c["project_id"]
groups[key]["project"] = c["project"]
@@ -268,22 +293,27 @@ def main():
print("\nTidak ada task yang ditemukan di Odoo.")
return
# 3. Hitung pro-rata jam
# 3. Hitung pro-rata berdasarkan time span
keys = list(groups.keys())
weights = [groups[k]["count"] for k in keys]
hours = distribute_hours(weights, total=float(TOTAL_HOURS))
spans = [span_minutes(groups[k]["timestamps"]) for k in keys]
hours = distribute_hours(spans, total=float(TOTAL_HOURS))
# 4. Tampilkan preview
print(f"\n{'' * 65}")
print(f" {'PROJECT':<20} {'TASK':<10} {'JAM':>5} {'COMMIT':>6} DESKRIPSI")
print(f"{'' * 65}")
print(f"\n{'' * 75}")
print(f" {'PROJECT':<20} {'TASK':<10} {'WAKTU':<13} {'SPAN':>6} {'JAM':>5} DESKRIPSI")
print(f"{'' * 75}")
entries = []
for key, h in zip(keys, hours):
for key, h, span in zip(keys, hours, spans):
g = groups[key]
desc = g["descriptions"][0] if len(g["descriptions"]) == 1 else \
"; ".join(dict.fromkeys(g["descriptions"])) # deduplicate, preserve order
print(f" {g['project']:<20} {str(g['task_id']):<10} {h:>5.2f} {g['count']:>6}x {desc[:35]}")
ts = sorted(g["timestamps"])
if len(ts) == 1:
waktu = ts[0].strftime("%H:%M") + " (1 commit)"
else:
waktu = f"{ts[0].strftime('%H:%M')}{ts[-1].strftime('%H:%M')}"
span_label = f"{span}m" if span > SINGLE_COMMIT_MINUTES else "30m*"
desc = "; ".join(dict.fromkeys(g["descriptions"]))
print(f" {g['project']:<20} {str(g['task_id']):<10} {waktu:<13} {span_label:>6} {h:>5.2f} {desc[:30]}")
entries.append({
"odoo": {
"name": desc,
@@ -297,8 +327,10 @@ def main():
"display": g,
})
print(f"{'' * 65}")
print(f" {'TOTAL':<20} {'':<10} {sum(hours):>5.2f} {sum(weights):>6}x")
print(f"{'' * 75}")
total_span = sum(spans)
print(f" {'TOTAL':<20} {'':<10} {'':<13} {total_span:>5}m {sum(hours):>5.2f}")
print(f" * single commit → minimum {SINGLE_COMMIT_MINUTES} menit")
if not_found:
print(f"\n {len(not_found)} commit diabaikan (task tidak ditemukan di Odoo)")