Initial commit: Odoo timesheet automation scripts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
run_daily.sh
|
||||||
|
.claude/
|
||||||
|
pending_*.json
|
||||||
|
done_*.json
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
236
README.md
Normal file
236
README.md
Normal file
@@ -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 <session_id> \
|
||||||
|
--author fajri \
|
||||||
|
--user-id 41 --employee-id 37 \
|
||||||
|
--dry-run
|
||||||
|
|
||||||
|
# Simpan pending.json + kirim notifikasi macOS
|
||||||
|
python3 daily_timesheet.py \
|
||||||
|
--session-id <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 <session_id>
|
||||||
|
|
||||||
|
# Tanggal tertentu
|
||||||
|
python3 upload_pending.py --session-id <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 <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 <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 <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 <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 <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` |
|
||||||
362
daily_timesheet.py
Executable file
362
daily_timesheet.py
Executable file
@@ -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()
|
||||||
87
git_commits.py
Normal file
87
git_commits.py
Normal file
@@ -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()
|
||||||
25
run_daily.sh.example
Normal file
25
run_daily.sh.example
Normal file
@@ -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"
|
||||||
129
search_task.py
Executable file
129
search_task.py
Executable file
@@ -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()
|
||||||
260
sync_timesheet.py
Executable file
260
sync_timesheet.py
Executable file
@@ -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()
|
||||||
139
upload_pending.py
Executable file
139
upload_pending.py
Executable file
@@ -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 <session_id_baru>")
|
||||||
|
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()
|
||||||
164
upload_timesheet.py
Executable file
164
upload_timesheet.py
Executable file
@@ -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()
|
||||||
Reference in New Issue
Block a user