206a800762
Adds two new tool categories — billing (10 tools) and legal_aid (6 tools) — so Shira can drive the full Green Invoice flow: list unbilled activities, manage charges (approve/cancel/calculate), build draft invoices from activities, sync to Green Invoice, plus the legal-aid sub-flow (proforma, mark-submitted, monthly 320 wrap-up, prepare-email, resubmit rejected). Wires both registrars into agent_runner with allowed_toolsets gating, and extends TOOL_RULES so Shira (a) shows draft totals before creating, (b) treats send_invoice_to_green_invoice as irreversible and demands explicit confirmation, and (c) never marks a legal-aid proforma as submitted without an explicit request number from the user. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
421 lines
18 KiB
Python
421 lines
18 KiB
Python
"""Green Invoice billing tools: activities, charges, invoices, sync to Green Invoice."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
from urllib.parse import urlencode
|
|
|
|
if TYPE_CHECKING:
|
|
from mcp_server.espocrm_client import EspoCrmClient
|
|
|
|
from mcp_server.tools._helpers import normalize_date, ok, fail
|
|
|
|
|
|
DOCUMENT_TYPES = {
|
|
"305": "חשבונית מס",
|
|
"320": "חשבונית מס/קבלה",
|
|
"330": "קבלה",
|
|
"400": "חשבון עסקה",
|
|
}
|
|
|
|
CHARGE_STATUSES = [
|
|
"Pending", "Approved", "Invoiced", "Cancelled",
|
|
"Recorded", "Submitted", "Paid", "Billed", "Rejected",
|
|
]
|
|
|
|
|
|
def _to_date(date_str: str | None) -> str | None:
|
|
if not date_str:
|
|
return None
|
|
norm = normalize_date(date_str, default_time="08:00:00")
|
|
if norm and len(norm) >= 10:
|
|
return norm[:10]
|
|
return norm
|
|
|
|
|
|
def _money(amount) -> str:
|
|
try:
|
|
return f"₪{float(amount):,.2f}"
|
|
except (TypeError, ValueError):
|
|
return f"₪{amount}"
|
|
|
|
|
|
def register_billing_tools(
|
|
tools: dict,
|
|
crm: "EspoCrmClient",
|
|
case_id: str | None,
|
|
user_id: str | None,
|
|
context: dict,
|
|
):
|
|
"""Register Green Invoice billing tools (regular flow: activities → charges → invoices → sync)."""
|
|
|
|
async def list_unbilled_activities(caseId: str = "") -> str:
|
|
target = caseId or case_id
|
|
if not target:
|
|
return fail("צריך מזהה תיק (caseId) או להיות בתוך תיק")
|
|
try:
|
|
result = await crm.get(f"Invoice/getUnbilledActivities?caseId={target}")
|
|
activities = result.get("list", [])
|
|
total = result.get("total", len(activities))
|
|
if not activities:
|
|
return ok("אין פעילויות שטרם חויבו בתיק זה.")
|
|
return ok(json.dumps({
|
|
"total": total,
|
|
"activities": [
|
|
{
|
|
"id": a.get("id"),
|
|
"name": a.get("name"),
|
|
"activityType": a.get("activityType"),
|
|
"activityDate": a.get("activityDate"),
|
|
"billUnits": a.get("billUnits"),
|
|
"billRate": a.get("billRate"),
|
|
"billAmount": a.get("billAmount"),
|
|
"description": a.get("description"),
|
|
}
|
|
for a in activities
|
|
],
|
|
}, ensure_ascii=False))
|
|
except Exception as e:
|
|
return fail(f"שגיאה בשליפת פעילויות לחיוב: {e}")
|
|
|
|
async def list_charges(
|
|
caseId: str = "",
|
|
accountId: str = "",
|
|
status: str = "",
|
|
) -> str:
|
|
target_case = caseId or case_id
|
|
try:
|
|
if status == "Approved":
|
|
qs_parts = []
|
|
if accountId:
|
|
qs_parts.append(f"accountId={accountId}")
|
|
if target_case:
|
|
qs_parts.append(f"caseId={target_case}")
|
|
endpoint = "Charge/action/getApprovedCharges"
|
|
if qs_parts:
|
|
endpoint += "?" + "&".join(qs_parts)
|
|
result = await crm.get(endpoint)
|
|
charges = result.get("list", [])
|
|
else:
|
|
where = []
|
|
idx = 0
|
|
if status:
|
|
where.extend([
|
|
(f"where[{idx}][type]", "equals"),
|
|
(f"where[{idx}][attribute]", "status"),
|
|
(f"where[{idx}][value]", status),
|
|
])
|
|
idx += 1
|
|
if target_case:
|
|
where.extend([
|
|
(f"where[{idx}][type]", "equals"),
|
|
(f"where[{idx}][attribute]", "caseId"),
|
|
(f"where[{idx}][value]", target_case),
|
|
])
|
|
idx += 1
|
|
if accountId:
|
|
where.extend([
|
|
(f"where[{idx}][type]", "equals"),
|
|
(f"where[{idx}][attribute]", "accountId"),
|
|
(f"where[{idx}][value]", accountId),
|
|
])
|
|
idx += 1
|
|
qs = urlencode(where) if where else ""
|
|
endpoint = f"Charge?{qs}" if qs else "Charge"
|
|
result = await crm.get(endpoint)
|
|
charges = result.get("list", [])
|
|
if not charges:
|
|
return ok("לא נמצאו חיובים תואמים.")
|
|
return ok(json.dumps({
|
|
"total": len(charges),
|
|
"charges": [
|
|
{
|
|
"id": c.get("id"),
|
|
"name": c.get("name"),
|
|
"number": c.get("number"),
|
|
"activityType": c.get("activityType"),
|
|
"chargeDate": c.get("chargeDate"),
|
|
"amount": c.get("amount"),
|
|
"status": c.get("status"),
|
|
"isLegalAid": c.get("isLegalAid"),
|
|
"caseId": c.get("caseId"),
|
|
}
|
|
for c in charges
|
|
],
|
|
}, ensure_ascii=False))
|
|
except Exception as e:
|
|
return fail(f"שגיאה בשליפת חיובים: {e}")
|
|
|
|
async def approve_charges(chargeIds: list[str]) -> str:
|
|
if not chargeIds:
|
|
return fail("צריך לפחות מזהה חיוב אחד")
|
|
try:
|
|
if len(chargeIds) == 1:
|
|
result = await crm.post("Charge/action/approve", {"id": chargeIds[0]})
|
|
return ok(f"חיוב אושר (ID: {result.get('id')}, סטטוס: {result.get('status')})")
|
|
result = await crm.post("Charge/action/approveMultiple", {"ids": chargeIds})
|
|
results = result.get("results", [])
|
|
success_count = sum(1 for r in results if r.get("success"))
|
|
failed = [r for r in results if not r.get("success")]
|
|
msg = f"אושרו {success_count} מתוך {len(chargeIds)} חיובים"
|
|
if failed:
|
|
msg += f". כשלים: {json.dumps(failed, ensure_ascii=False)}"
|
|
return ok(msg)
|
|
except Exception as e:
|
|
return fail(f"שגיאה באישור חיובים: {e}")
|
|
|
|
async def cancel_charges(chargeIds: list[str]) -> str:
|
|
if not chargeIds:
|
|
return fail("צריך לפחות מזהה חיוב אחד")
|
|
try:
|
|
if len(chargeIds) == 1:
|
|
result = await crm.post("Charge/action/cancel", {"id": chargeIds[0]})
|
|
return ok(f"חיוב בוטל (ID: {result.get('id')}, סטטוס: {result.get('status')})")
|
|
result = await crm.post("Charge/action/cancelMultiple", {"ids": chargeIds})
|
|
results = result.get("results", [])
|
|
success_count = sum(1 for r in results if r.get("success"))
|
|
failed = [r for r in results if not r.get("success")]
|
|
msg = f"בוטלו {success_count} מתוך {len(chargeIds)} חיובים"
|
|
if failed:
|
|
msg += f". כשלים: {json.dumps(failed, ensure_ascii=False)}"
|
|
return ok(msg)
|
|
except Exception as e:
|
|
return fail(f"שגיאה בביטול חיובים: {e}")
|
|
|
|
async def calculate_charges_total(chargeIds: list[str]) -> str:
|
|
if not chargeIds:
|
|
return fail("צריך לפחות מזהה חיוב אחד")
|
|
try:
|
|
result = await crm.post("Charge/action/calculateTotal", {"ids": chargeIds})
|
|
total = result.get("total", 0)
|
|
count = result.get("count", len(chargeIds))
|
|
return ok(f"סכום כולל ל-{count} חיובים: {_money(total)}")
|
|
except Exception as e:
|
|
return fail(f"שגיאה בחישוב סכום חיובים: {e}")
|
|
|
|
async def create_invoice_from_activities(
|
|
activityIds: list[str],
|
|
documentType: str = "320",
|
|
name: str = "",
|
|
issueDate: str = "",
|
|
dueDate: str = "",
|
|
vatRate: float | None = None,
|
|
notes: str = "",
|
|
) -> str:
|
|
if not activityIds:
|
|
return fail("צריך לפחות מזהה פעילות אחד")
|
|
if documentType not in DOCUMENT_TYPES:
|
|
return fail(f"סוג מסמך לא תקין: {documentType}. תקינים: {list(DOCUMENT_TYPES.keys())}")
|
|
body = {
|
|
"activityIds": activityIds,
|
|
"documentType": documentType,
|
|
"name": name or None,
|
|
"issueDate": _to_date(issueDate),
|
|
"dueDate": _to_date(dueDate),
|
|
"vatRate": vatRate,
|
|
"notes": notes or None,
|
|
}
|
|
body = {k: v for k, v in body.items() if v is not None}
|
|
try:
|
|
result = await crm.post("Invoice/createFromActivities", body)
|
|
if not result.get("success"):
|
|
return fail(f"יצירת חשבונית נכשלה: {result.get('error', 'unknown')}")
|
|
doc_label = DOCUMENT_TYPES.get(documentType, documentType)
|
|
return ok(
|
|
f"{doc_label} נוצרה במצב טיוטה (ID: {result.get('id')}, "
|
|
f"סכום כולל: {_money(result.get('totalAmount'))}). "
|
|
f"החשבונית עדיין לא נשלחה לחשבונית ירוקה."
|
|
)
|
|
except Exception as e:
|
|
return fail(f"שגיאה ביצירת חשבונית: {e}")
|
|
|
|
async def add_activity_to_invoice(invoiceId: str, activityId: str) -> str:
|
|
if not invoiceId or not activityId:
|
|
return fail("צריך מזהה חשבונית ומזהה פעילות")
|
|
try:
|
|
result = await crm.post(f"Invoice/{invoiceId}/addActivity", {"activityId": activityId})
|
|
if not result.get("success"):
|
|
return fail(f"הוספת פעילות נכשלה: {result.get('error', 'unknown')}")
|
|
return ok(f"פעילות נוספה לחשבונית. סכום כולל מעודכן: {_money(result.get('totalAmount'))}")
|
|
except Exception as e:
|
|
return fail(f"שגיאה בהוספת פעילות לחשבונית: {e}")
|
|
|
|
async def remove_activity_from_invoice(invoiceId: str, activityId: str) -> str:
|
|
if not invoiceId or not activityId:
|
|
return fail("צריך מזהה חשבונית ומזהה פעילות")
|
|
try:
|
|
result = await crm.post(f"Invoice/{invoiceId}/removeActivity", {"activityId": activityId})
|
|
if not result.get("success"):
|
|
return fail(f"הסרת פעילות נכשלה: {result.get('error', 'unknown')}")
|
|
return ok(f"פעילות הוסרה מהחשבונית. סכום כולל מעודכן: {_money(result.get('totalAmount'))}")
|
|
except Exception as e:
|
|
return fail(f"שגיאה בהסרת פעילות מחשבונית: {e}")
|
|
|
|
async def recalculate_invoice(invoiceId: str) -> str:
|
|
if not invoiceId:
|
|
return fail("צריך מזהה חשבונית")
|
|
try:
|
|
result = await crm.post(f"Invoice/{invoiceId}/recalculate", {})
|
|
return ok(
|
|
f"חשבונית חושבה מחדש: סכום {_money(result.get('amount'))}, "
|
|
f"מע״מ {_money(result.get('vatAmount'))}, סה״כ {_money(result.get('totalAmount'))}"
|
|
)
|
|
except Exception as e:
|
|
return fail(f"שגיאה בחישוב מחדש של חשבונית: {e}")
|
|
|
|
async def send_invoice_to_green_invoice(invoiceId: str) -> str:
|
|
if not invoiceId:
|
|
return fail("צריך מזהה חשבונית")
|
|
try:
|
|
result = await crm.post(f"Invoice/{invoiceId}/sendToGreenInvoice", {})
|
|
if not result.get("success"):
|
|
err = result.get("error") or "שגיאה לא ידועה"
|
|
return fail(f"שליחה לחשבונית ירוקה נכשלה: {err}")
|
|
data = result.get("data", {}) or {}
|
|
gi_number = data.get("greenInvoiceNumber") or data.get("number")
|
|
gi_url = data.get("url") or data.get("greenInvoiceUrl")
|
|
msg = f"חשבונית נשלחה לחשבונית ירוקה (מס׳ מסמך: {gi_number})"
|
|
if gi_url:
|
|
msg += f"\nקישור: {gi_url}"
|
|
return ok(msg)
|
|
except Exception as e:
|
|
return fail(f"שגיאה בשליחה לחשבונית ירוקה: {e}")
|
|
|
|
tools["list_unbilled_activities"] = {
|
|
"description": "List billable case activities that have not yet been included in any invoice. Defaults to current case if caseId is omitted.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"caseId": {"type": "string", "description": "Case ID. Optional — defaults to current case context."},
|
|
},
|
|
},
|
|
"handler": list_unbilled_activities,
|
|
}
|
|
|
|
tools["list_charges"] = {
|
|
"description": "List charges (חיובים) filtered by case, account, and/or status. Use status='Approved' for charges ready to invoice; 'Pending' for ones awaiting approval; 'Recorded'/'Submitted'/'Paid'/'Rejected' for legal-aid charges.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"caseId": {"type": "string", "description": "Case ID filter (optional, defaults to current case)"},
|
|
"accountId": {"type": "string", "description": "Account ID filter (optional)"},
|
|
"status": {"type": "string", "enum": CHARGE_STATUSES, "description": "Charge status filter"},
|
|
},
|
|
},
|
|
"handler": list_charges,
|
|
}
|
|
|
|
tools["approve_charges"] = {
|
|
"description": "Approve one or more charges (Pending → Approved). Required before they can be invoiced.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"chargeIds": {"type": "array", "items": {"type": "string"}, "description": "One or more charge IDs"},
|
|
},
|
|
"required": ["chargeIds"],
|
|
},
|
|
"handler": approve_charges,
|
|
}
|
|
|
|
tools["cancel_charges"] = {
|
|
"description": "Cancel one or more charges (Pending/Approved → Cancelled). Terminal — cancelled charges cannot be reactivated.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"chargeIds": {"type": "array", "items": {"type": "string"}, "description": "One or more charge IDs"},
|
|
},
|
|
"required": ["chargeIds"],
|
|
},
|
|
"handler": cancel_charges,
|
|
}
|
|
|
|
tools["calculate_charges_total"] = {
|
|
"description": "Calculate the total monetary sum across selected charges. Use to preview an invoice total before creating it.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"chargeIds": {"type": "array", "items": {"type": "string"}, "description": "One or more charge IDs"},
|
|
},
|
|
"required": ["chargeIds"],
|
|
},
|
|
"handler": calculate_charges_total,
|
|
}
|
|
|
|
tools["create_invoice_from_activities"] = {
|
|
"description": (
|
|
"Create a draft Invoice from selected unbilled CaseActivity records. "
|
|
"Document types: 305=Tax Invoice (חשבונית מס), 320=Tax Invoice+Receipt (default, חשבונית מס/קבלה), "
|
|
"330=Receipt (קבלה), 400=Proforma (חשבון עסקה — for legal-aid). "
|
|
"Returned invoice is in Draft state — does NOT auto-send to Green Invoice."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"activityIds": {"type": "array", "items": {"type": "string"}, "description": "Activity IDs to bundle"},
|
|
"documentType": {"type": "string", "enum": list(DOCUMENT_TYPES.keys()), "description": "Document type code (default 320)"},
|
|
"name": {"type": "string", "description": "Optional invoice name (auto-generated if omitted)"},
|
|
"issueDate": {"type": "string", "description": "Issue date — any format (defaults to today)"},
|
|
"dueDate": {"type": "string", "description": "Due date — any format"},
|
|
"vatRate": {"type": "number", "description": "VAT % (default 17)"},
|
|
"notes": {"type": "string", "description": "Free-text notes"},
|
|
},
|
|
"required": ["activityIds"],
|
|
},
|
|
"handler": create_invoice_from_activities,
|
|
}
|
|
|
|
tools["add_activity_to_invoice"] = {
|
|
"description": "Link an unbilled activity to an existing draft invoice and recalculate totals.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"invoiceId": {"type": "string", "description": "Invoice ID"},
|
|
"activityId": {"type": "string", "description": "Activity ID to add"},
|
|
},
|
|
"required": ["invoiceId", "activityId"],
|
|
},
|
|
"handler": add_activity_to_invoice,
|
|
}
|
|
|
|
tools["remove_activity_from_invoice"] = {
|
|
"description": "Unlink an activity from an invoice and recalculate totals.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"invoiceId": {"type": "string", "description": "Invoice ID"},
|
|
"activityId": {"type": "string", "description": "Activity ID to remove"},
|
|
},
|
|
"required": ["invoiceId", "activityId"],
|
|
},
|
|
"handler": remove_activity_from_invoice,
|
|
}
|
|
|
|
tools["recalculate_invoice"] = {
|
|
"description": "Recalculate amount, VAT, and total of an invoice from its linked activities. Useful after activity rates change.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"invoiceId": {"type": "string", "description": "Invoice ID"},
|
|
},
|
|
"required": ["invoiceId"],
|
|
},
|
|
"handler": recalculate_invoice,
|
|
}
|
|
|
|
tools["send_invoice_to_green_invoice"] = {
|
|
"description": (
|
|
"IRREVERSIBLE: Push a draft invoice to Green Invoice external service to issue an official tax document. "
|
|
"After this, linked activities are flagged as billed and linked charges become Invoiced. "
|
|
"MUST get explicit user confirmation before calling — do not call on a vague 'yes'."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"invoiceId": {"type": "string", "description": "Invoice ID to send"},
|
|
},
|
|
"required": ["invoiceId"],
|
|
},
|
|
"handler": send_invoice_to_green_invoice,
|
|
}
|