import json import os import uuid from datetime import datetime from pathlib import Path from . import tool_integration_write DATA_PATH = Path(os.getenv('AGX_OPERATION_PATH', '/opt/microdao-daarion/data/operations/operation_plans.jsonl')) STATUS_FLOW = { 'planned': ['scheduled', 'cancelled'], 'scheduled': ['in_progress', 'cancelled'], 'in_progress': ['done', 'cancelled'], 'done': ['verified', 'closed'], 'verified': ['closed'], 'closed': [], 'cancelled': [] } def _now(): return datetime.utcnow().isoformat() + 'Z' def _append_event(ev: dict): DATA_PATH.parent.mkdir(parents=True, exist_ok=True) with DATA_PATH.open('a', encoding='utf-8') as f: f.write(json.dumps(ev, ensure_ascii=False) + '\n') def _load_events(): if not DATA_PATH.exists(): return [] events = [] for line in DATA_PATH.read_text(encoding='utf-8').split('\n'): if not line.strip(): continue events.append(json.loads(line)) return events def _project(): plans = {} for ev in _load_events(): pid = ev.get('plan_id') if ev['type'] == 'create_plan': plans[pid] = ev['payload'] elif ev['type'] == 'update_plan' and pid in plans: plans[pid].update(ev['payload']) plans[pid]['updated_ts'] = ev['ts'] elif ev['type'] == 'set_status' and pid in plans: plans[pid]['status'] = ev['payload']['status'] plans[pid]['updated_ts'] = ev['ts'] elif ev['type'] == 'record_fact' and pid in plans: plans[pid].setdefault('fact_events', []).append(ev['payload']) plans[pid]['updated_ts'] = ev['ts'] return plans def _new_id(prefix: str): return f"{prefix}_{uuid.uuid4().hex[:8]}" def create_plan(plan_spec: dict, trace_id: str = '', source: str = 'telegram'): plan_id = f"opplan_{datetime.utcnow().strftime('%Y%m%d')}_{uuid.uuid4().hex[:6]}" plan = { 'plan_id': plan_id, 'created_ts': _now(), 'updated_ts': _now(), 'trace_id': trace_id, 'source': source, 'status': 'planned', 'scope': plan_spec.get('scope', {}), 'tasks': [], 'fact_events': [] } for task in plan_spec.get('tasks', []): t = task.copy() t['task_id'] = t.get('task_id') or _new_id('task') plan['tasks'].append(t) _append_event({ 'ts': _now(), 'type': 'create_plan', 'plan_id': plan_id, 'trace_id': trace_id, 'payload': plan }) return plan_id def list_plans(filters: dict | None = None): plans = list(_project().values()) if not filters: return plans status = filters.get('status') if status: plans = [p for p in plans if p.get('status') == status] return plans def get_plan(plan_id: str): return _project().get(plan_id) def update_plan(plan_id: str, patch: dict, trace_id: str = ''): _append_event({ 'ts': _now(), 'type': 'update_plan', 'plan_id': plan_id, 'trace_id': trace_id, 'payload': patch }) return True def set_status(plan_id: str, status: str, trace_id: str = ''): plan = get_plan(plan_id) if not plan: raise ValueError('plan_not_found') current = plan.get('status') if status not in STATUS_FLOW.get(current, []): raise ValueError('invalid_transition') _append_event({ 'ts': _now(), 'type': 'set_status', 'plan_id': plan_id, 'trace_id': trace_id, 'payload': {'status': status} }) return True def record_fact(plan_id: str, fact_event: dict, trace_id: str = ''): plan = get_plan(plan_id) if not plan: raise ValueError('plan_not_found') fact = fact_event.copy() fact['fact_id'] = fact.get('fact_id') or _new_id('fact') fact.setdefault('farmos_write', {'status': 'pending', 'ref': ''}) # write to farmOS via integration service (single-writer) try: tool_integration_write.write_tasklog( {'source': 'operation_plan', 'deviceId': fact.get('field_id', '')}, { 'task': fact.get('operation_id', ''), 'status': 'done', 'ts': int(datetime.utcnow().timestamp() * 1000), 'notes': json.dumps(fact.get('fact', {}), ensure_ascii=False) } ) fact['farmos_write'] = {'status': 'ok', 'ref': ''} except Exception: fact['farmos_write'] = {'status': 'failed', 'ref': ''} _append_event({ 'ts': _now(), 'type': 'record_fact', 'plan_id': plan_id, 'trace_id': trace_id, 'payload': fact }) return True def plan_dashboard(date_range: dict | None = None, filters: dict | None = None): plans = list_plans(filters) counts = {'planned': 0, 'in_progress': 0, 'done': 0, 'overdue': 0} critical_tasks = [] today = datetime.utcnow().date().isoformat() for p in plans: status = p.get('status') if status in counts: counts[status] += 1 for t in p.get('tasks', []): planned_date = t.get('planned_date') if planned_date and planned_date < today and status not in ['done', 'closed', 'verified']: counts['overdue'] += 1 critical_tasks.append({ 'field_id': p.get('scope', {}).get('field_ids', [''])[0], 'operation_id': t.get('operation_id'), 'planned_date': planned_date, 'reason': 'overdue' }) if t.get('priority') == 'critical': critical_tasks.append({ 'field_id': p.get('scope', {}).get('field_ids', [''])[0], 'operation_id': t.get('operation_id'), 'planned_date': planned_date, 'reason': 'critical' }) return { 'status': 'ok', 'date_range': date_range or {}, 'counts': counts, 'critical_tasks': critical_tasks, 'plan_vs_fact': [] }