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:
@@ -3,8 +3,11 @@
|
|||||||
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 = jumlah commit per task. Total = 8 jam.
|
Pro-rata: bobot = time span per task (last commit - first commit).
|
||||||
Contoh: Task A 3 commit + Task B 1 commit → A=6h, B=2h.
|
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
|
import json
|
||||||
@@ -13,7 +16,7 @@ import subprocess
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import argparse
|
import argparse
|
||||||
from datetime import date, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
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"--after={today}",
|
||||||
f"--before={until}",
|
f"--before={until}",
|
||||||
"--format=%h|%ad|%s",
|
"--format=%h|%ad|%s",
|
||||||
"--date=short",
|
"--date=format:%Y-%m-%d %H:%M",
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True)
|
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
|
continue
|
||||||
parts = line.split("|", 2)
|
parts = line.split("|", 2)
|
||||||
if len(parts) == 3:
|
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
|
return commits
|
||||||
|
|
||||||
|
|
||||||
# ── Pro-rata ──────────────────────────────────────────────────────────────────
|
# ── 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]:
|
def distribute_hours(weights: list[int], total: float = 8.0) -> list[float]:
|
||||||
"""
|
"""Pro-rata berdasarkan bobot (menit span). Sum = total. Adjustment di entry terakhir."""
|
||||||
Distribusi jam pro-rata berdasarkan bobot (jumlah commit).
|
|
||||||
Adjustment di entry terakhir supaya sum persis = total.
|
|
||||||
"""
|
|
||||||
total_weight = sum(weights)
|
total_weight = sum(weights)
|
||||||
raw = [Decimal(str(total)) * Decimal(w) / Decimal(total_weight) for w in 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]
|
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),
|
"code": m.group(1),
|
||||||
"description": m.group(2),
|
"description": m.group(2),
|
||||||
"date": c["date"],
|
"date": c["date"],
|
||||||
|
"time": c["time"],
|
||||||
|
"dt": c["dt"],
|
||||||
"hash": c["hash"],
|
"hash": c["hash"],
|
||||||
"repo": repo_name,
|
"repo": repo_name,
|
||||||
"project": repo["project"],
|
"project": repo["project"],
|
||||||
@@ -238,9 +260,11 @@ def main():
|
|||||||
|
|
||||||
task_cache = {} # (code, project_id) → (task_id, task_name) | None
|
task_cache = {} # (code, project_id) → (task_id, task_name) | None
|
||||||
|
|
||||||
# Group: key = (task_id, project_id), value = list of commit descriptions & count
|
# Group: key = (task_id, project_id)
|
||||||
groups = defaultdict(lambda: {"descriptions": [], "count": 0, "project_id": None,
|
groups = defaultdict(lambda: {
|
||||||
"project": None, "task_name": None, "date": None})
|
"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:
|
||||||
@@ -257,6 +281,7 @@ def main():
|
|||||||
task_id, task_name = task
|
task_id, task_name = task
|
||||||
key = (task_id, c["project_id"])
|
key = (task_id, c["project_id"])
|
||||||
groups[key]["descriptions"].append(c["description"])
|
groups[key]["descriptions"].append(c["description"])
|
||||||
|
groups[key]["timestamps"].append(c["dt"])
|
||||||
groups[key]["count"] += 1
|
groups[key]["count"] += 1
|
||||||
groups[key]["project_id"] = c["project_id"]
|
groups[key]["project_id"] = c["project_id"]
|
||||||
groups[key]["project"] = c["project"]
|
groups[key]["project"] = c["project"]
|
||||||
@@ -268,22 +293,27 @@ def main():
|
|||||||
print("\nTidak ada task yang ditemukan di Odoo.")
|
print("\nTidak ada task yang ditemukan di Odoo.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3. Hitung pro-rata jam
|
# 3. Hitung pro-rata berdasarkan time span
|
||||||
keys = list(groups.keys())
|
keys = list(groups.keys())
|
||||||
weights = [groups[k]["count"] for k in keys]
|
spans = [span_minutes(groups[k]["timestamps"]) for k in keys]
|
||||||
hours = distribute_hours(weights, total=float(TOTAL_HOURS))
|
hours = distribute_hours(spans, total=float(TOTAL_HOURS))
|
||||||
|
|
||||||
# 4. Tampilkan preview
|
# 4. Tampilkan preview
|
||||||
print(f"\n{'─' * 65}")
|
print(f"\n{'─' * 75}")
|
||||||
print(f" {'PROJECT':<20} {'TASK':<10} {'JAM':>5} {'COMMIT':>6} DESKRIPSI")
|
print(f" {'PROJECT':<20} {'TASK':<10} {'WAKTU':<13} {'SPAN':>6} {'JAM':>5} DESKRIPSI")
|
||||||
print(f"{'─' * 65}")
|
print(f"{'─' * 75}")
|
||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
for key, h in zip(keys, hours):
|
for key, h, span in zip(keys, hours, spans):
|
||||||
g = groups[key]
|
g = groups[key]
|
||||||
desc = g["descriptions"][0] if len(g["descriptions"]) == 1 else \
|
ts = sorted(g["timestamps"])
|
||||||
"; ".join(dict.fromkeys(g["descriptions"])) # deduplicate, preserve order
|
if len(ts) == 1:
|
||||||
print(f" {g['project']:<20} {str(g['task_id']):<10} {h:>5.2f} {g['count']:>6}x {desc[:35]}")
|
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({
|
entries.append({
|
||||||
"odoo": {
|
"odoo": {
|
||||||
"name": desc,
|
"name": desc,
|
||||||
@@ -297,8 +327,10 @@ def main():
|
|||||||
"display": g,
|
"display": g,
|
||||||
})
|
})
|
||||||
|
|
||||||
print(f"{'─' * 65}")
|
print(f"{'─' * 75}")
|
||||||
print(f" {'TOTAL':<20} {'':<10} {sum(hours):>5.2f} {sum(weights):>6}x")
|
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:
|
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)")
|
||||||
|
|||||||
Reference in New Issue
Block a user