commit c9bf004a4f02e8d64b35ca92bedc3889ddd37a2f Author: sas.fajri Date: Thu May 28 10:18:08 2026 +0700 Initial commit: Odoo timesheet automation scripts Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..491017b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +run_daily.sh +.claude/ +pending_*.json +done_*.json +logs/ +*.log +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..22b4cb9 --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ +# Odoo Timesheet Automation + +Script untuk otomatisasi input timesheet Odoo berdasarkan commit Git harian. + +--- + +## Alur Harian + +``` +Commit dengan format "TASKCODE - deskripsi" + ↓ +16:55 — cron otomatis baca commit hari ini dari semua repo + ↓ +Cari task_id di Odoo berdasarkan kode task + ↓ +Hitung jam pro-rata (total = 8 jam/hari) + ↓ +Simpan ke pending_YYYY-MM-DD.json + kirim notifikasi macOS + ↓ +Kamu jalankan upload_pending.py --session-id XXX + ↓ +Preview → konfirmasi y/N → upload ke Odoo +``` + +--- + +## Format Commit + +Setiap commit **wajib** menggunakan format: + +``` +TASKCODE - deskripsi singkat +``` + +Contoh: +``` +6D9QD6 - buat api endpoint baru +FHM28 - fix bug validasi form +``` + +Kode task akan dipakai untuk mencari task di Odoo secara otomatis. + +--- + +## Repo yang Dipantau + +| Repo | Project Odoo | +|------|-------------| +| `REPO_CPONE/BE_CPONE` | CPONE | +| `REPO_CPONE/FE_CPONE` | CPONE | +| `REPO_CPONE_DASHBOARD` | CPONE | +| `REPO_GITEA_IBL` | IBL | +| `REPO_GITEA_KD` | Support Kedungdoro | +| `REPO_GITLAB_PRAMITA/bisone` | Support Pramita | + +## Project ID + +| Nama | ID | +|------|----| +| CPONE | 123 | +| IBL | 186 | +| Support Pramita | 70 | +| SAS | 92 | +| Support Kedungdoro | 77 | + +--- + +## Scripts + +### `daily_timesheet.py` +Ambil commit hari ini dari semua repo, cari task di Odoo, hitung pro-rata, simpan pending. + +```bash +# Dry run — preview tanpa simpan +python3 daily_timesheet.py \ + --session-id \ + --author fajri \ + --user-id 41 --employee-id 37 \ + --dry-run + +# Simpan pending.json + kirim notifikasi macOS +python3 daily_timesheet.py \ + --session-id \ + --author fajri \ + --user-id 41 --employee-id 37 \ + --save-pending + +# Tanggal custom +python3 daily_timesheet.py ... --date 2026-05-27 +``` + +| Parameter | Keterangan | +|-----------|-----------| +| `--session-id` | Cookie session Odoo | +| `--author` | Filter git author | +| `--user-id` | ID user Odoo | +| `--employee-id` | ID employee Odoo | +| `--date` | Tanggal (default: hari ini) | +| `--dry-run` | Preview saja, tidak upload | +| `--save-pending` | Simpan ke file + notifikasi | + +--- + +### `upload_pending.py` +Upload timesheet dari file pending hasil `daily_timesheet.py`. Jalankan setelah menerima notifikasi macOS. + +```bash +python3 upload_pending.py --session-id + +# Tanggal tertentu +python3 upload_pending.py --session-id --date 2026-05-27 +``` + +Setelah upload sukses, file `pending_YYYY-MM-DD.json` otomatis diganti nama jadi `done_YYYY-MM-DD.json`. + +--- + +### `search_task.py` +Cari task di Odoo berdasarkan kode dan project. + +```bash +python3 search_task.py \ + --session-id \ + --name "[6D9QD6]" \ + --project-id CPONE +``` + +| Parameter | Keterangan | +|-----------|-----------| +| `--name` | Kata kunci pencarian | +| `--project-id` | Nama project atau ID angka | +| `--limit` | Maks hasil (default: 8) | + +--- + +### `upload_timesheet.py` +Upload satu atau banyak timesheet entry secara manual. + +```bash +# Satu entry +python3 upload_timesheet.py \ + --session-id \ + --name "review API" \ + --task-id 51352 \ + --unit-amount 2.0 \ + --user-id 41 --employee-id 37 \ + --project-id CPONE + +# Banyak entry sekaligus +python3 upload_timesheet.py \ + --session-id \ + --name "review API" "fix bug" \ + --task-id 51352 51353 \ + --unit-amount 2.0 1.5 \ + --user-id 41 41 \ + --employee-id 37 37 \ + --project-id CPONE SAS +``` + +--- + +### `git_commits.py` +Lihat commit dari semua repo per project tanpa upload ke Odoo. + +```bash +python3 git_commits.py --author fajri --since 2026-05-01 --until 2026-05-28 +``` + +--- + +### `sync_timesheet.py` +Upload timesheet langsung dari commit (tanpa pending flow). Cocok untuk upload manual hari-hari sebelumnya. + +```bash +# Dry run +python3 sync_timesheet.py \ + --session-id \ + --author fajri \ + --user-id 41 --employee-id 37 \ + --since 2026-05-27 --until 2026-05-27 \ + --dry-run + +# Upload +python3 sync_timesheet.py \ + --session-id \ + --author fajri \ + --user-id 41 --employee-id 37 \ + --since 2026-05-27 +``` + +--- + +## Cron Otomatis + +Cron berjalan setiap Senin–Jumat jam **16:55**: + +``` +55 16 * * 1-5 run_daily.sh +``` + +Edit `run_daily.sh` untuk mengubah `SESSION_ID` jika expired: + +```bash +# run_daily.sh +SESSION_ID="session_id_baru_disini" +``` + +Log tersimpan di `logs/daily_YYYY-MM-DD.log`. + +--- + +## Session ID Expired + +Kalau session expired, notifikasi macOS akan muncul: + +``` +⚠️ Timesheet Gagal +Session Odoo expired. Buka run_daily.sh dan ganti SESSION_ID. +``` + +**Cara ambil session_id baru:** +1. Buka Odoo di browser → login +2. Tekan `F12` → tab **Application** (Chrome) / **Storage** (Firefox) +3. Pilih **Cookies** → cari `session_id` +4. Copy nilainya → paste ke `SESSION_ID` di `run_daily.sh` + +--- + +## Konfigurasi Default + +| Parameter | Nilai | +|-----------|-------| +| User ID | 41 | +| Employee ID | 37 | +| Total jam/hari | 8 | +| Odoo URL | `https://odoo.minipc.sismedika.biz.id` | diff --git a/daily_timesheet.py b/daily_timesheet.py new file mode 100755 index 0000000..a44f05e --- /dev/null +++ b/daily_timesheet.py @@ -0,0 +1,362 @@ +#!/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 = jumlah commit per task. Total = 8 jam. +Contoh: Task A 3 commit + Task B 1 commit → A=6h, B=2h. +""" + +import json +import re +import subprocess +import urllib.request +import urllib.error +import argparse +from datetime import date, timedelta +from collections import defaultdict +from decimal import Decimal, ROUND_HALF_UP + +BASE_URL = "https://odoo.minipc.sismedika.biz.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", "project": "IBL"}, + {"path": "/Users/fajrihardhitamurti/REPO_GITEA_KD", "project": "Support Kedungdoro"}, + {"path": "/Users/fajrihardhitamurti/REPO_GITLAB_PRAMITA/bisone", "project": "Support Pramita"}, +] + +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(session_id: str, code: str, project_id: int) -> tuple[int, str] | None: + 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 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]: + until = (date.fromisoformat(today) + timedelta(days=1)).isoformat() + cmd = [ + "git", "-C", repo_path, "log", + f"--author={author}", + f"--after={today}", + f"--before={until}", + "--format=%h|%ad|%s", + "--date=short", + ] + 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: + commits.append({"hash": parts[0], "date": parts[1], "message": parts[2]}) + return commits + + +# ── Pro-rata ────────────────────────────────────────────────────────────────── + +def distribute_hours(weights: list[int], total: float = 8.0) -> list[float]: + """ + Distribusi jam pro-rata berdasarkan bobot (jumlah commit). + Adjustment di entry terakhir supaya sum persis = total. + """ + 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", required=True, help="session_id cookie Odoo") + parser.add_argument("--author", required=True, help="Git author filter, e.g. 'fajri'") + parser.add_argument("--user-id", required=True, type=int, help="ID user Odoo") + parser.add_argument("--employee-id", required=True, 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") + args = parser.parse_args() + + print(f"Tanggal : {args.date}") + print(f"Author : {args.author}") + print(f"Mode : {'DRY RUN' if args.dry_run else 'UPLOAD'}") + 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"], + "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), value = list of commit descriptions & count + groups = defaultdict(lambda: {"descriptions": [], "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 project {c['project']} ({c['hash']})") + continue + + task_id, task_name = task + key = (task_id, c["project_id"]) + groups[key]["descriptions"].append(c["description"]) + groups[key]["count"] += 1 + groups[key]["project_id"] = c["project_id"] + groups[key]["project"] = c["project"] + 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 jam + keys = list(groups.keys()) + weights = [groups[k]["count"] for k in keys] + hours = distribute_hours(weights, total=float(TOTAL_HOURS)) + + # 4. Tampilkan preview + print(f"\n{'─' * 65}") + print(f" {'PROJECT':<20} {'TASK':<10} {'JAM':>5} {'COMMIT':>6} DESKRIPSI") + print(f"{'─' * 65}") + + entries = [] + for key, h in zip(keys, hours): + g = groups[key] + desc = g["descriptions"][0] if len(g["descriptions"]) == 1 else \ + "; ".join(dict.fromkeys(g["descriptions"])) # deduplicate, preserve order + print(f" {g['project']:<20} {str(g['task_id']):<10} {h:>5.2f} {g['count']:>6}x {desc[:35]}") + 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"{'─' * 65}") + print(f" {'TOTAL':<20} {'':<10} {sum(hours):>5.2f} {sum(weights):>6}x") + + 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 + 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() diff --git a/git_commits.py b/git_commits.py new file mode 100644 index 0000000..f21e6b6 --- /dev/null +++ b/git_commits.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Ambil commit dari beberapa repo dan tampilkan per project.""" + +import subprocess +import argparse +from datetime import date, timedelta +from collections import defaultdict + +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", "project": "IBL"}, + {"path": "/Users/fajrihardhitamurti/REPO_GITEA_KD", "project": "Support Kedungdoro"}, + {"path": "/Users/fajrihardhitamurti/REPO_GITLAB_PRAMITA/bisone", "project": "Support Pramita"}, +] + + +def get_commits(repo_path: str, author: str, since: str, until: str) -> list[dict]: + cmd = [ + "git", "-C", repo_path, + "log", + f"--author={author}", + f"--after={since}", + f"--before={until}", + "--format=%h|%ad|%s", + "--date=short", + ] + 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: + commits.append({"hash": parts[0], "date": parts[1], "message": parts[2]}) + return commits + + +def main(): + today = date.today().isoformat() + yesterday = (date.today() - timedelta(days=1)).isoformat() + + parser = argparse.ArgumentParser(description="Ambil commit dari semua repo per project") + parser.add_argument("--author", required=True, help="Filter by git author name/email, e.g. 'fajri'") + parser.add_argument("--since", default=today, help=f"Tanggal mulai YYYY-MM-DD (default: hari ini {today})") + parser.add_argument("--until", default=today, help=f"Tanggal akhir YYYY-MM-DD (default: hari ini {today})") + args = parser.parse_args() + + # --until perlu +1 hari karena git --before bersifat eksklusif + until_exclusive = (date.fromisoformat(args.until) + timedelta(days=1)).isoformat() + + print(f"Author : {args.author}") + print(f"Periode: {args.since} s/d {args.until}") + print("=" * 60) + + grouped = defaultdict(list) + for repo in REPOS: + commits = get_commits(repo["path"], args.author, args.since, until_exclusive) + repo_name = repo["path"].split("/")[-1] + for c in commits: + grouped[repo["project"]].append({**c, "repo": repo_name}) + + if not any(grouped.values()): + print("Tidak ada commit ditemukan.") + return + + total = 0 + for project, commits in grouped.items(): + if not commits: + continue + print(f"\n[{project}] — {len(commits)} commit") + print("-" * 60) + for c in commits: + print(f" {c['date']} {c['hash']} ({c['repo']})") + print(f" {c['message']}") + total += len(commits) + + print(f"\nTotal: {total} commit dari {len([p for p in grouped if grouped[p]])} project") + + +if __name__ == "__main__": + main() diff --git a/run_daily.sh.example b/run_daily.sh.example new file mode 100644 index 0000000..e2ffa55 --- /dev/null +++ b/run_daily.sh.example @@ -0,0 +1,25 @@ +#!/bin/zsh +# Salin file ini ke run_daily.sh lalu isi SESSION_ID +# cp run_daily.sh.example run_daily.sh + +SESSION_ID="your_session_id_here" +AUTHOR="fajri" +USER_ID=41 +EMPLOYEE_ID=37 + +SCRIPT_DIR="/Users/fajrihardhitamurti/ODOO _TIMESHEET" +LOG="$SCRIPT_DIR/logs/daily_$(date +%Y-%m-%d).log" + +mkdir -p "$SCRIPT_DIR/logs" + +echo "=== $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG" + +/opt/homebrew/bin/python3 "$SCRIPT_DIR/daily_timesheet.py" \ + --session-id "$SESSION_ID" \ + --author "$AUTHOR" \ + --user-id $USER_ID \ + --employee-id $EMPLOYEE_ID \ + --save-pending \ + >> "$LOG" 2>&1 + +echo "Log: $LOG" diff --git a/search_task.py b/search_task.py new file mode 100755 index 0000000..b580d2c --- /dev/null +++ b/search_task.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Cari task di Odoo berdasarkan nama dan project.""" + +import json +import urllib.request +import urllib.error +import argparse + +BASE_URL = "https://odoo.minipc.sismedika.biz.id" + +PROJECT_MAP = { + "CPONE": 123, + "IBL": 186, + "Support Pramita": 70, + "SAS": 92, + "Support Kedungdoro": 77, +} + + +def resolve_project(value: str) -> int: + if value in PROJECT_MAP: + return PROJECT_MAP[value] + try: + return int(value) + except ValueError: + names = ", ".join(PROJECT_MAP.keys()) + raise argparse.ArgumentTypeError( + f"Project '{value}' tidak dikenali. Pilihan: {names}, atau masukkan ID angka." + ) + + +def build_headers(session_id: str) -> dict: + return { + "accept": "*/*", + "accept-language": "en-US,en;q=0.9,id;q=0.8", + "cache-control": "no-cache", + "content-type": "application/json", + "origin": BASE_URL, + "pragma": "no-cache", + "referer": f"{BASE_URL}/web", + "user-agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/148.0.0.0 Safari/537.36" + ), + "cookie": f"frontend_lang=en_US; cids=1; session_id={session_id}; tz=Asia/Jakarta", + } + + +def search_task(session_id: str, name: str, project_id: int, limit: int = 8) -> list: + payload = { + "id": 1, + "jsonrpc": "2.0", + "method": "call", + "params": { + "model": "project.task", + "method": "name_search", + "args": [], + "kwargs": { + "name": name, + "operator": "ilike", + "args": [ + "&", "&", "&", + ["company_id", "=", 1], + ["project_id.allow_timesheets", "=", True], + ["stage_id.fold", "=", False], + ["project_id", "=", project_id], + ], + "limit": limit, + "context": { + "lang": "en_US", + "tz": "Asia/Jakarta", + "uid": 41, + "allowed_company_ids": [1], + "params": {"menu_id": 274, "action": 394, "cids": 1}, + "is_timesheet": 1, + "default_project_id": project_id, + "hr_timesheet_display_remaining_hours": True, + }, + }, + }, + } + + url = f"{BASE_URL}/web/dataset/call_kw/project.task/name_search" + 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('code')}]: {err.get('message')}") + + return response.get("result", []) + + +def main(): + project_names = ", ".join(PROJECT_MAP.keys()) + parser = argparse.ArgumentParser(description="Cari task Odoo berdasarkan nama dan project") + parser.add_argument("--session-id", required=True, help="session_id cookie") + parser.add_argument("--name", required=True, help="Kata kunci pencarian task, e.g. '[FHM28052601]'") + parser.add_argument("--project-id", required=True, type=resolve_project, help=f"Nama project ({project_names}) atau ID angka") + parser.add_argument("--limit", default=8, type=int, help="Maksimal hasil yang ditampilkan (default: 8)") + args = parser.parse_args() + + print(f"Mencari task '{args.name}' di project {args.project_id}...\n") + + results = search_task( + session_id=args.session_id, + name=args.name, + project_id=args.project_id, + limit=args.limit, + ) + + if not results: + print("Tidak ada task yang ditemukan.") + return + + print(f"Ditemukan {len(results)} task:\n") + for task_id, task_name in results: + print(f" ID: {task_id:<8} | {task_name}") + + +if __name__ == "__main__": + main() diff --git a/sync_timesheet.py b/sync_timesheet.py new file mode 100755 index 0000000..b7bbee4 --- /dev/null +++ b/sync_timesheet.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +Baca commit dari semua repo, parse task code, cari task di Odoo, lalu upload timesheet. + +Format commit yang dikenali: "TASKCODE - deskripsi pekerjaan" +Contoh: "6D9QD6 - buat api baru" +""" + +import json +import re +import subprocess +import urllib.request +import urllib.error +import argparse +from datetime import date, timedelta +from collections import defaultdict + +BASE_URL = "https://odoo.minipc.sismedika.biz.id" + +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", "project": "IBL"}, + {"path": "/Users/fajrihardhitamurti/REPO_GITEA_KD", "project": "Support Kedungdoro"}, + {"path": "/Users/fajrihardhitamurti/REPO_GITLAB_PRAMITA/bisone", "project": "Support Pramita"}, +] + +# Regex: "TASKCODE - deskripsi" +COMMIT_RE = re.compile(r"^([A-Z0-9]+)\s*-\s*(.+)$") + + +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 + + +def search_task(session_id: str, code: str, project_id: int) -> tuple[int, str] | None: + 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 upload_timesheet(session_id: str, entry: dict) -> 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": entry["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") + + +def get_commits(repo_path: str, author: str, since: str, until: str) -> list[dict]: + cmd = [ + "git", "-C", repo_path, + "log", + f"--author={author}", + f"--after={since}", + f"--before={until}", + "--format=%h|%ad|%s", + "--date=short", + ] + 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: + commits.append({"hash": parts[0], "date": parts[1], "message": parts[2]}) + return commits + + +def main(): + today = date.today().isoformat() + + parser = argparse.ArgumentParser( + description="Sync commit ke timesheet Odoo", + formatter_class=argparse.RawTextHelpFormatter, + ) + parser.add_argument("--session-id", required=True, help="session_id cookie Odoo") + parser.add_argument("--author", required=True, help="Filter git author, e.g. 'fajri'") + parser.add_argument("--user-id", required=True, type=int, help="ID user Odoo") + parser.add_argument("--employee-id", required=True, type=int, help="ID employee Odoo") + parser.add_argument("--unit-amount", default=1.0, type=float, help="Jam per commit (default: 1.0)") + parser.add_argument("--since", default=today, help=f"Tanggal mulai YYYY-MM-DD (default: {today})") + parser.add_argument("--until", default=today, help=f"Tanggal akhir YYYY-MM-DD (default: {today})") + parser.add_argument("--dry-run", action="store_true", help="Preview saja, tidak upload ke Odoo") + args = parser.parse_args() + + until_exclusive = (date.fromisoformat(args.until) + timedelta(days=1)).isoformat() + + print(f"Author : {args.author}") + print(f"Periode : {args.since} s/d {args.until}") + print(f"Mode : {'DRY RUN (preview only)' if args.dry_run else 'UPLOAD'}") + print("=" * 65) + + # Kumpulkan commit yang formatnya cocok, per project + candidates = [] + skipped = [] + + for repo in REPOS: + commits = get_commits(repo["path"], args.author, args.since, until_exclusive) + repo_name = repo["path"].split("/")[-1] + project_name = repo["project"] + project_id = PROJECT_MAP[project_name] + + for c in commits: + m = COMMIT_RE.match(c["message"]) + if m: + candidates.append({ + "code": m.group(1), + "description": m.group(2), + "date": c["date"], + "hash": c["hash"], + "repo": repo_name, + "project": project_name, + "project_id": project_id, + }) + else: + skipped.append(f" SKIP {c['hash']} ({repo_name}): {c['message']}") + + if skipped: + print(f"\n{len(skipped)} commit dilewati (format tidak cocok):") + for s in skipped: + print(s) + + if not candidates: + print("\nTidak ada commit dengan format yang cocok.") + return + + print(f"\n{len(candidates)} commit akan diproses:\n") + + # Search task_id di Odoo untuk tiap commit + entries = [] + for c in candidates: + task = search_task(args.session_id, c["code"], c["project_id"]) + if task: + task_id, task_name = task + status = f"task {task_id}" + else: + task_id, task_name = None, "(task tidak ditemukan)" + status = "TIDAK DITEMUKAN" + + print(f" [{c['project']}] {c['date']} {c['hash']}") + print(f" commit : {c['code']} - {c['description']}") + print(f" task : {status} — {task_name}") + print() + + if task_id: + entries.append({ + "name": c["description"], + "date": c["date"], + "unit_amount": args.unit_amount, + "user_id": args.user_id, + "employee_id": args.employee_id, + "task_id": task_id, + "project_id": c["project_id"], + }) + + if not entries: + print("Tidak ada entry yang bisa diupload.") + return + + print(f"{'=' * 65}") + print(f"Siap upload: {len(entries)} entry, {len(candidates) - len(entries)} gagal (task tidak ditemukan)") + + if args.dry_run: + print("\n[DRY RUN] Tidak ada yang diupload.") + return + + confirm = input(f"\nUpload {len(entries)} timesheet? (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) + print(f" OK ID {new_id} | {e['date']} {e['name']}") + ok += 1 + except RuntimeError as ex: + print(f" ERR {e['date']} {e['name']} -> {ex}") + fail += 1 + + print(f"\nSelesai: {ok} sukses, {fail} gagal.") + + +if __name__ == "__main__": + main() diff --git a/upload_pending.py b/upload_pending.py new file mode 100755 index 0000000..3c9ac0d --- /dev/null +++ b/upload_pending.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Upload timesheet dari file pending yang dibuat daily_timesheet.py. +Jalankan ini setelah menerima notifikasi macOS jam 16:55. +""" + +import json +import urllib.request +import urllib.error +import argparse +import glob +import os +from datetime import date + +BASE_URL = "https://odoo.minipc.sismedika.biz.id" +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) + + +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 check_session(session_id: str) -> bool: + 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 upload_timesheet(session_id: str, entry: dict) -> 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": entry["user_id"], + "allowed_company_ids": [1], "is_timesheet": 1, + } + }, + }, + } + url = f"{BASE_URL}/web/dataset/call_kw/account.analytic.line/create" + 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.get("result") + + +def find_pending(target_date: str) -> str | None: + path = os.path.join(SCRIPT_DIR, f"pending_{target_date}.json") + if os.path.exists(path): + return path + # fallback: cari pending terbaru + files = sorted(glob.glob(os.path.join(SCRIPT_DIR, "pending_*.json")), reverse=True) + return files[0] if files else None + + +def main(): + today = date.today().isoformat() + + parser = argparse.ArgumentParser(description="Upload timesheet dari pending file") + parser.add_argument("--session-id", required=True, help="session_id cookie Odoo") + parser.add_argument("--date", default=today, help=f"Tanggal pending (default: {today})") + args = parser.parse_args() + + pending_path = find_pending(args.date) + if not pending_path: + print("Tidak ada file pending ditemukan.") + return + + if not check_session(args.session_id): + print("SESSION EXPIRED — login ulang ke Odoo, ambil session_id baru dari browser,") + print("lalu jalankan ulang dengan --session-id ") + return + + with open(pending_path) as f: + pending = json.load(f) + + entries = pending["entries"] + print(f"File : {os.path.basename(pending_path)}") + print(f"Tanggal : {pending['date']}") + print(f"Entries : {len(entries)}") + print() + print(f" {'PROJECT_ID':<12} {'TASK_ID':<10} {'JAM':>5} DESKRIPSI") + print(f" {'─' * 60}") + for e in entries: + print(f" {str(e['project_id']):<12} {str(e['task_id']):<10} {e['unit_amount']:>5.2f} {e['name'][:40]}") + print(f" {'─' * 60}") + print(f" {'TOTAL':<23} {sum(e['unit_amount'] for e in entries):>5.2f}h") + + confirm = input(f"\nUpload {len(entries)} entry ke Odoo? (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) + print(f" OK ID {new_id:<8} task {e['task_id']} {e['unit_amount']}h {e['name'][:40]}") + ok += 1 + except RuntimeError as ex: + print(f" ERR task {e['task_id']} {e['name'][:40]} → {ex}") + fail += 1 + + if ok == len(entries): + os.rename(pending_path, pending_path.replace("pending_", "done_")) + print(f"\nSelesai: {ok} sukses. File dipindah ke done_*.") + else: + print(f"\nSelesai: {ok} sukses, {fail} gagal. File pending tidak dihapus.") + + +if __name__ == "__main__": + main() diff --git a/upload_timesheet.py b/upload_timesheet.py new file mode 100755 index 0000000..0801c03 --- /dev/null +++ b/upload_timesheet.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Upload satu atau banyak timesheet entry ke Odoo sekaligus.""" + +import json +import urllib.request +import urllib.error +import argparse +from datetime import date + +BASE_URL = "https://odoo.minipc.sismedika.biz.id" + +PROJECT_MAP = { + "CPONE": 123, + "IBL": 186, + "Support Pramita": 70, + "SAS": 92, + "Support Kedungdoro": 77, +} + + +def resolve_project(value: str) -> int: + if value in PROJECT_MAP: + return PROJECT_MAP[value] + try: + return int(value) + except ValueError: + names = ", ".join(PROJECT_MAP.keys()) + raise argparse.ArgumentTypeError( + f"Project '{value}' tidak dikenali. Pilihan: {names}, atau masukkan ID angka." + ) + + +def build_headers(session_id: str) -> dict: + return { + "accept": "*/*", + "accept-language": "en-US,en;q=0.9,id;q=0.8", + "cache-control": "no-cache", + "content-type": "application/json", + "origin": BASE_URL, + "pragma": "no-cache", + "referer": f"{BASE_URL}/web", + "user-agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/148.0.0.0 Safari/537.36" + ), + "cookie": f"frontend_lang=en_US; cids=1; session_id={session_id}; tz=Asia/Jakarta", + } + + +def upload_timesheet(session_id: str, entry: dict) -> dict: + 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": entry["user_id"], + "allowed_company_ids": [1], + "params": {"menu_id": 274, "action": 394, "cids": 1}, + "is_timesheet": 1, + } + }, + }, + } + + url = f"{BASE_URL}/web/dataset/call_kw/account.analytic.line/create" + 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('code')}]: {err.get('message')}") + + return response + + +def main(): + today = date.today().isoformat() + + parser = argparse.ArgumentParser( + description="Upload timesheet ke Odoo. Semua parameter (kecuali --session-id) " + "bisa diisi lebih dari satu nilai untuk upload banyak entry sekaligus.", + formatter_class=argparse.RawTextHelpFormatter, + ) + parser.add_argument("--session-id", required=True, help="session_id cookie (satu nilai)") + parser.add_argument("--name", required=True, nargs="+", help="Deskripsi pekerjaan") + parser.add_argument("--task-id", required=True, nargs="+", type=int, help="ID task di Odoo") + parser.add_argument("--unit-amount", required=True, nargs="+", type=float, help="Jam (0.5 = 30 menit)") + parser.add_argument("--user-id", required=True, nargs="+", type=int, help="ID user Odoo") + parser.add_argument("--employee-id", required=True, nargs="+", type=int, help="ID employee Odoo") + parser.add_argument("--project-id", required=True, nargs="+", type=resolve_project, + help="Nama project (CPONE, IBL, 'Support Pramita', SAS) atau ID angka") + parser.add_argument("--date", nargs="+", default=[today], help=f"Tanggal YYYY-MM-DD (default: {today})") + args = parser.parse_args() + + # Pastikan semua array punya panjang sama + lengths = { + "name": len(args.name), + "task-id": len(args.task_id), + "unit-amount": len(args.unit_amount), + "user-id": len(args.user_id), + "employee-id": len(args.employee_id), + "project-id": len(args.project_id), + } + unique = set(lengths.values()) + + # Kalau --date cuma 1, broadcast ke semua entry + dates = args.date + if len(dates) == 1: + dates = dates * max(unique) + else: + lengths["date"] = len(dates) + unique = set(lengths.values()) + + if len(unique) != 1: + mismatches = ", ".join(f"--{k}={v}" for k, v in lengths.items()) + parser.error(f"Jumlah nilai tiap parameter harus sama.\nSekarang: {mismatches}") + + entries = list(zip( + args.name, + args.task_id, + args.unit_amount, + args.user_id, + args.employee_id, + args.project_id, + dates, + )) + + total = len(entries) + print(f"Akan mengupload {total} entry timesheet...\n") + + for i, (name, task_id, unit_amount, user_id, employee_id, project_id, entry_date) in enumerate(entries, 1): + entry = { + "name": name, + "date": entry_date, + "unit_amount": unit_amount, + "user_id": user_id, + "task_id": task_id, + "project_id": project_id, + "employee_id": employee_id, + } + print(f"[{i}/{total}] '{name}' | task={task_id} | {unit_amount}h | {entry_date}") + result = upload_timesheet(args.session_id, entry) + new_id = result.get("result") + print(f" Sukses — timesheet ID: {new_id}") + + print(f"\nSelesai. {total} entry berhasil diupload.") + + +if __name__ == "__main__": + main()