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:
42
README.md
42
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.
|
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:00–11:00 → span 08:00–11:00 = 180 menit (first commit hari ini)
|
commit 1 10:26 → span 08:00→10:26 = 146 menit (first)
|
||||||
Task B commit 14:00–15: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:00–15: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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user