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:
40
README.md
40
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
|
||||
@@ -162,12 +168,14 @@ python3 daily_timesheet.py --session-id <id> --user-id 41 --employee-id 37 --aut
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
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:
|
||||
start = first
|
||||
|
||||
return max(
|
||||
int((last - start).total_seconds() / 60),
|
||||
SINGLE_COMMIT_MINUTES,
|
||||
)
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user