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.
**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:0011:00 → span 08:0011:00 = 180 menit (first commit hari ini)
Task B commit 14:0015: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 <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:0015: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
```
---

View File

@@ -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}")