#!/usr/bin/env python3 """ 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 """ import json import os import re import subprocess 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 def load_env() -> dict: """Baca .env dari direktori script, return dict key=value.""" env_path = Path(__file__).parent / ".env" env = {} if env_path.exists(): for line in env_path.read_text().splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) env[k.strip()] = v.strip() return env ENV = load_env() BASE_URL = "https://odoo.aplikasi.web.id" TOTAL_HOURS = 8 PROJECT_MAP = { "CPONE": 123, "IBL": 186, "Support Pramita": 70, "SAS": 92, "Support Kedungdoro": 77, } REPOS = [ {"path": "/Users/fajrihardhitamurti/REPO_CPONE/BE_CPONE", "project": "CPONE"}, {"path": "/Users/fajrihardhitamurti/REPO_CPONE/FE_CPONE", "project": "CPONE"}, {"path": "/Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD", "project": "CPONE"}, {"path": "/Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL", "project": "IBL"}, {"path": "/Users/fajrihardhitamurti/REPO_GITEA_IBL/FE_IBL", "project": "IBL"}, {"path": "/Users/fajrihardhitamurti/REPO_GITEA_KD", "project": "Support Kedungdoro"}, {"path": "/Users/fajrihardhitamurti/REPO_GITLAB_PRAMITA/bisone", "project": "Support Pramita"}, {"path": "/Users/fajrihardhitamurti/SAS_TASK", "project": "SAS"}, ] COMMIT_RE = re.compile(r"^([A-Z0-9]+)\s*-\s*(.+)$") # ── HTTP ────────────────────────────────────────────────────────────────────── def build_headers(session_id: str) -> dict: return { "accept": "*/*", "content-type": "application/json", "origin": BASE_URL, "referer": f"{BASE_URL}/web", "user-agent": "Mozilla/5.0", "cookie": f"frontend_lang=en_US; cids=1; session_id={session_id}; tz=Asia/Jakarta", } def odoo_call(session_id: str, endpoint: str, payload: dict) -> dict: url = f"{BASE_URL}{endpoint}" body = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=body, headers=build_headers(session_id), method="POST") try: with urllib.request.urlopen(req) as resp: response = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: raise RuntimeError(f"HTTP {e.code}: {e.read().decode()}") from e if "error" in response: err = response["error"] raise RuntimeError(f"Odoo error: {err.get('message')}") return response # ── Odoo helpers ────────────────────────────────────────────────────────────── def check_session(session_id: str) -> bool: """Return True jika session masih valid, False jika expired.""" payload = {"jsonrpc": "2.0", "method": "call", "id": 1, "params": {}} url = f"{BASE_URL}/web/session/get_session_info" body = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=body, headers=build_headers(session_id), method="POST") try: with urllib.request.urlopen(req) as resp: response = json.loads(resp.read().decode("utf-8")) uid = response.get("result", {}).get("uid") return bool(uid) except Exception: return False def notify_session_expired(): import os os.system( 'osascript -e \'display notification ' '"Session Odoo expired. Buka run_daily.sh dan ganti SESSION_ID." ' 'with title "⚠️ Timesheet Gagal" ' 'subtitle "Session ID perlu diperbarui" ' 'sound name "Basso"\'' ) print("SESSION EXPIRED — update SESSION_ID di run_daily.sh") def search_task_in_project(session_id: str, code: str, project_id: int) -> tuple[int, str] | None: """Cari task di satu project spesifik.""" payload = { "id": 1, "jsonrpc": "2.0", "method": "call", "params": { "model": "project.task", "method": "name_search", "args": [], "kwargs": { "name": f"[{code}]", "operator": "ilike", "args": [ "&", "&", "&", ["company_id", "=", 1], ["project_id.allow_timesheets", "=", True], ["stage_id.fold", "=", False], ["project_id", "=", project_id], ], "limit": 1, "context": { "lang": "en_US", "tz": "Asia/Jakarta", "uid": 41, "allowed_company_ids": [1], "is_timesheet": 1, "default_project_id": project_id, }, }, }, } result = odoo_call(session_id, "/web/dataset/call_kw/project.task/name_search", payload) items = result.get("result", []) return tuple(items[0]) if items else None def search_task(session_id: str, code: str, project_id: int) -> tuple[int, str, int] | None: """ Cari task mulai dari project default repo. Kalau tidak ketemu, fallback cari di semua project lain. Return (task_id, task_name, actual_project_id). """ result = search_task_in_project(session_id, code, project_id) if result: return result[0], result[1], project_id for name, pid in PROJECT_MAP.items(): if pid == project_id: continue result = search_task_in_project(session_id, code, pid) if result: return result[0], result[1], pid return None def upload_timesheet(session_id: str, entry: dict, user_id: int) -> int: payload = { "id": 1, "jsonrpc": "2.0", "method": "call", "params": { "model": "account.analytic.line", "method": "create", "args": [entry], "kwargs": { "context": { "lang": "en_US", "tz": "Asia/Jakarta", "uid": user_id, "allowed_company_ids": [1], "is_timesheet": 1, } }, }, } result = odoo_call(session_id, "/web/dataset/call_kw/account.analytic.line/create", payload) return result.get("result") # ── Git ─────────────────────────────────────────────────────────────────────── def get_commits_today(repo_path: str, author: str, today: str) -> list[dict]: cmd = [ "git", "-C", repo_path, "log", f"--author={author}", f"--since={today} 00:00:00", f"--until={today} 23:59:59", "--format=%h|%ad|%s", "--date=format:%Y-%m-%d %H:%M", ] try: out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True) except subprocess.CalledProcessError: return [] commits = [] for line in out.strip().splitlines(): if not line: continue parts = line.split("|", 2) if len(parts) == 3: 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 WORK_START_HOUR = 8 # jam mulai kerja def span_minutes(timestamps: list[datetime], extend_to_work_start: bool = False) -> 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). """ 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, ) def distribute_hours(weights: list[int], total: float = 8.0) -> list[float]: """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] diff = round(total - sum(rounded), 2) rounded[-1] = round(rounded[-1] + diff, 2) return rounded # ── Main ────────────────────────────────────────────────────────────────────── def main(): today = date.today().isoformat() parser = argparse.ArgumentParser(description="Daily timesheet: commit → Odoo (8 jam pro-rata)") parser.add_argument("--session-id", default=ENV.get("SESSION_ID"), help="session_id cookie Odoo") parser.add_argument("--author", default=ENV.get("AUTHOR"), help="Git author filter") parser.add_argument("--user-id", default=ENV.get("USER_ID"), type=int, help="ID user Odoo") parser.add_argument("--employee-id", default=ENV.get("EMPLOYEE_ID"), type=int, help="ID employee Odoo") parser.add_argument("--date", default=today, help=f"Tanggal YYYY-MM-DD (default: {today})") parser.add_argument("--dry-run", action="store_true", help="Preview saja, tidak upload") parser.add_argument("--save-pending", action="store_true", help="Simpan ke pending.json + notifikasi macOS") parser.add_argument("--yes", action="store_true", help="Skip konfirmasi, langsung upload") args = parser.parse_args() print(f"Tanggal : {args.date}") print(f"Author : {args.author}") if args.dry_run: mode = "DRY RUN" elif args.save_pending: mode = "SAVE PENDING" else: mode = "UPLOAD" print(f"Mode : {mode}") print("=" * 65) # Cek session sebelum mulai if not check_session(args.session_id): notify_session_expired() return # 1. Kumpulkan semua commit hari ini dari semua repo raw_commits = [] for repo in REPOS: commits = get_commits_today(repo["path"], args.author, args.date) repo_name = repo["path"].split("/")[-1] for c in commits: m = COMMIT_RE.match(c["message"]) if m: raw_commits.append({ "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"], "project_id": PROJECT_MAP[repo["project"]], }) else: print(f" SKIP {c['hash']} ({repo_name}): {c['message']}") if not raw_commits: print("\nTidak ada commit dengan format TASKCODE - deskripsi hari ini.") return # 2. Cari task_id di Odoo, group by (task_id, project_id) 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, }) not_found = [] for c in raw_commits: cache_key = (c["code"], c["project_id"]) if cache_key not in task_cache: task_cache[cache_key] = search_task(args.session_id, c["code"], c["project_id"]) task = task_cache[cache_key] if not task: not_found.append(c) print(f" NOT FOUND [{c['code']}] di semua project ({c['hash']})") continue task_id, task_name, actual_project_id = task 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: 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 ] hours = distribute_hours(spans, total=float(TOTAL_HOURS)) # 4. Tampilkan preview print(f"\n{'─' * 78}") print(f" {'PROJECT':<20} {'TASK':<10} {'WAKTU':<15} {'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) 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)" else: start_label = f"{ts[0].strftime('%H:%M')}–{ts[-1].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]}") entries.append({ "odoo": { "name": desc, "date": g["date"], "unit_amount": h, "user_id": args.user_id, "employee_id": args.employee_id, "task_id": g["task_id"], "project_id": g["project_id"], }, "display": g, }) 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") if not_found: print(f"\n {len(not_found)} commit diabaikan (task tidak ditemukan di Odoo)") if args.dry_run: print("\n[DRY RUN] Tidak ada yang diupload.") return # 5. Simpan pending.json + notifikasi macOS if args.save_pending: import os pending_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), f"pending_{args.date}.json" ) pending = { "date": args.date, "user_id": args.user_id, "employee_id": args.employee_id, "entries": [e["odoo"] for e in entries], } with open(pending_path, "w") as f: json.dump(pending, f, indent=2) summary = ", ".join( f"{e['odoo']['unit_amount']}h {e['display']['project']}" for e in entries ) msg = f"{len(entries)} entry siap ({summary})" os.system( f'osascript -e \'display notification "{msg}" ' f'with title "Timesheet {args.date}" ' f'subtitle "Jalankan: python3 upload_pending.py --session-id XXX"\'' ) print(f"\nPending disimpan: {pending_path}") print("Notifikasi macOS dikirim.") return # 6. Konfirmasi & upload if not args.yes: confirm = input(f"\nUpload {len(entries)} timesheet entry? (y/N): ").strip().lower() if confirm != "y": print("Dibatalkan.") return print() ok, fail = 0, 0 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]}") ok += 1 except RuntimeError as ex: print(f" ERR {e['odoo']['name'][:40]} → {ex}") fail += 1 print(f"\nSelesai: {ok} sukses, {fail} gagal.") if __name__ == "__main__": main()