feat(billing): teach Shira the GreenInvoiceBilling extension surface
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>
This commit is contained in:
@@ -14,6 +14,8 @@ from mcp_server.tools.document_tools import register_document_tools
|
|||||||
from mcp_server.tools.legal_tools import register_legal_tools
|
from mcp_server.tools.legal_tools import register_legal_tools
|
||||||
from mcp_server.tools.legal_kb_tools import register_legal_kb_tools
|
from mcp_server.tools.legal_kb_tools import register_legal_kb_tools
|
||||||
from mcp_server.tools.signature_tools import register_signature_tools
|
from mcp_server.tools.signature_tools import register_signature_tools
|
||||||
|
from mcp_server.tools.billing_tools import register_billing_tools
|
||||||
|
from mcp_server.tools.legal_aid_tools import register_legal_aid_tools
|
||||||
from api.services.skills import register_skill_tools
|
from api.services.skills import register_skill_tools
|
||||||
from api.services.memory import register_memory_tools
|
from api.services.memory import register_memory_tools
|
||||||
from api.services.delegate import register_delegate_tool
|
from api.services.delegate import register_delegate_tool
|
||||||
@@ -111,7 +113,7 @@ class AgentRunner:
|
|||||||
Args:
|
Args:
|
||||||
depth: Current delegation depth. 0 = main conversation, 1 = child agent.
|
depth: Current delegation depth. 0 = main conversation, 1 = child agent.
|
||||||
blocked_tools: Tool names to exclude (used by delegation to prevent recursion).
|
blocked_tools: Tool names to exclude (used by delegation to prevent recursion).
|
||||||
allowed_toolsets: If set, only register tools from these categories (crm, documents, legal).
|
allowed_toolsets: If set, only register tools from these categories (crm, documents, legal, signature, billing, legal_aid).
|
||||||
on_event: Optional sync callback fired between iterations and tool calls.
|
on_event: Optional sync callback fired between iterations and tool calls.
|
||||||
Receives a dict {type, label, ...} where type ∈ {thinking,
|
Receives a dict {type, label, ...} where type ∈ {thinking,
|
||||||
tool_start, tool_done, tool_error}. Used by /kb/ask/stream
|
tool_start, tool_done, tool_error}. Used by /kb/ask/stream
|
||||||
@@ -150,6 +152,10 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
if should_register_all or "signature" in (allowed_toolsets or []):
|
if should_register_all or "signature" in (allowed_toolsets or []):
|
||||||
register_signature_tools(tools_registry, crm, case_id, user_id, context)
|
register_signature_tools(tools_registry, crm, case_id, user_id, context)
|
||||||
|
if should_register_all or "billing" in (allowed_toolsets or []):
|
||||||
|
register_billing_tools(tools_registry, crm, case_id, user_id, context)
|
||||||
|
if should_register_all or "legal_aid" in (allowed_toolsets or []):
|
||||||
|
register_legal_aid_tools(tools_registry, crm, case_id, user_id, context)
|
||||||
|
|
||||||
# Phase 3 tools (only for main conversation, not child agents)
|
# Phase 3 tools (only for main conversation, not child agents)
|
||||||
if depth == 0:
|
if depth == 0:
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ TOOL_RULES = (
|
|||||||
"- When user asks to summarize multiple documents or the entire case folder — use read_multiple_documents with all relevant file paths.\n"
|
"- When user asks to summarize multiple documents or the entire case folder — use read_multiple_documents with all relevant file paths.\n"
|
||||||
"- When user asks to rename or organize files — use batch_rename_documents with an array of rename operations.\n"
|
"- When user asks to rename or organize files — use batch_rename_documents with an array of rename operations.\n"
|
||||||
"- TEMPLATE GENERATION: When user asks to generate/create a document from a template — use generate_from_template. The templateId comes from availableTemplates in your context.\n"
|
"- TEMPLATE GENERATION: When user asks to generate/create a document from a template — use generate_from_template. The templateId comes from availableTemplates in your context.\n"
|
||||||
|
"- BILLING (חיוב/חשבונית): When user asks about billable activities, charges, or invoices — start with list_unbilled_activities for a case, or list_charges for charge status views. Show the user a summary BEFORE creating an invoice.\n"
|
||||||
|
"- BILLING — invoice creation: create_invoice_from_activities only creates a Draft. It does NOT auto-send to Green Invoice. Always show the user the draft amount and ask if they want to send it before calling send_invoice_to_green_invoice.\n"
|
||||||
|
"- BILLING — irreversible: send_invoice_to_green_invoice issues an official tax document and CANNOT be undone. Require an explicit instruction like 'תשלחי לחשבונית ירוקה', not a vague 'כן'. If unsure, ask again.\n"
|
||||||
|
"- BILLING — charges: A Charge moves Pending → Approved → (invoiced via create_invoice_from_activities). Cancel only if user explicitly asks. Never approve in bulk without listing the IDs and amounts first.\n"
|
||||||
|
"- LEGAL AID (סיוע משפטי): the flow is recorded charges → create_legal_aid_proforma → user submits manually at the official site → mark_legal_aid_submitted with the request number → payment report ingestion (automated) → create_monthly_legal_aid_invoice for the month. NEVER call mark_legal_aid_submitted without an explicit request number provided by the user. Use legal_aid_monthly_summary to preview before create_monthly_legal_aid_invoice.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
LEGAL_ASSISTANCE_PROMPT = (
|
LEGAL_ASSISTANCE_PROMPT = (
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
"""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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Legal-aid billing tools: proforma → submission → monthly 320 → email payload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mcp_server.espocrm_client import EspoCrmClient
|
||||||
|
|
||||||
|
from mcp_server.tools._helpers import ok, fail
|
||||||
|
|
||||||
|
|
||||||
|
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _money(amount) -> str:
|
||||||
|
try:
|
||||||
|
return f"₪{float(amount):,.2f}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return f"₪{amount}"
|
||||||
|
|
||||||
|
|
||||||
|
def register_legal_aid_tools(
|
||||||
|
tools: dict,
|
||||||
|
crm: "EspoCrmClient",
|
||||||
|
case_id: str | None,
|
||||||
|
user_id: str | None,
|
||||||
|
context: dict,
|
||||||
|
):
|
||||||
|
"""Register Israeli Legal-Aid (סיוע משפטי) billing tools."""
|
||||||
|
|
||||||
|
async def create_legal_aid_proforma(chargeIds: list[str]) -> str:
|
||||||
|
if not chargeIds:
|
||||||
|
return fail("צריך לפחות מזהה חיוב סיוע משפטי אחד")
|
||||||
|
try:
|
||||||
|
result = await crm.post("Invoice/action/createLegalAidProforma", {"chargeIds": chargeIds})
|
||||||
|
if not result.get("success"):
|
||||||
|
return fail(f"יצירת חשבון עסקה נכשלה: {result.get('error', 'unknown')}")
|
||||||
|
return ok(
|
||||||
|
f"חשבון עסקה (פרופורמה לסיוע משפטי) נוצר: "
|
||||||
|
f"{result.get('name', '')} (ID: {result.get('id')}, "
|
||||||
|
f"מספר חיובים: {result.get('chargesCount', len(chargeIds))}, "
|
||||||
|
f"סכום כולל: {_money(result.get('totalAmount'))}). "
|
||||||
|
f"כעת יש להגיש ידנית באתר משרד הסיוע ולאחר מכן לקרוא ל-mark_legal_aid_submitted עם מספר הבקשה שהאתר יחזיר."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return fail(f"שגיאה ביצירת פרופורמה: {e}")
|
||||||
|
|
||||||
|
async def mark_legal_aid_submitted(invoiceId: str, legalAidRequestNumber: str) -> str:
|
||||||
|
if not invoiceId:
|
||||||
|
return fail("צריך מזהה חשבון עסקה (פרופורמה)")
|
||||||
|
if not legalAidRequestNumber:
|
||||||
|
return fail("צריך מספר בקשה מאתר משרד הסיוע (לדוגמה: 2026-04-00123)")
|
||||||
|
try:
|
||||||
|
result = await crm.post(
|
||||||
|
f"Invoice/{invoiceId}/action/markLegalAidSubmitted",
|
||||||
|
{"legalAidRequestNumber": legalAidRequestNumber},
|
||||||
|
)
|
||||||
|
if not result.get("success"):
|
||||||
|
return fail(f"סימון הגשה נכשל: {result.get('error', 'unknown')}")
|
||||||
|
return ok(
|
||||||
|
f"פרופורמה סומנה כמוגשת. מספר בקשה: {result.get('legalAidRequestNumber')}, "
|
||||||
|
f"זמן הגשה: {result.get('legalAidSubmittedAt')}. "
|
||||||
|
f"כל החיובים המקושרים עברו לסטטוס Submitted."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return fail(f"שגיאה בסימון הגשה: {e}")
|
||||||
|
|
||||||
|
async def legal_aid_monthly_summary(month: str) -> str:
|
||||||
|
if not MONTH_RE.match(month or ""):
|
||||||
|
return fail("פורמט חודש חייב להיות YYYY-MM (לדוגמה 2026-04)")
|
||||||
|
try:
|
||||||
|
result = await crm.get(f"Invoice/action/legalAidMonthlyWrapSummary?month={month}")
|
||||||
|
return ok(json.dumps(result, ensure_ascii=False))
|
||||||
|
except Exception as e:
|
||||||
|
return fail(f"שגיאה בשליפת סיכום חודשי: {e}")
|
||||||
|
|
||||||
|
async def create_monthly_legal_aid_invoice(month: str) -> str:
|
||||||
|
if not MONTH_RE.match(month or ""):
|
||||||
|
return fail("פורמט חודש חייב להיות YYYY-MM (לדוגמה 2026-04)")
|
||||||
|
try:
|
||||||
|
result = await crm.post("Invoice/action/createLegalAidMonthlyTaxInvoice", {"month": month})
|
||||||
|
if not result.get("success"):
|
||||||
|
return fail(f"יצירת חשבונית חודשית נכשלה: {result.get('error', 'unknown')}")
|
||||||
|
return ok(
|
||||||
|
f"חשבונית מס חודשית (320) לחודש {month} נוצרה: "
|
||||||
|
f"{result.get('name', '')} (ID: {result.get('id')}, "
|
||||||
|
f"סכום כולל: {_money(result.get('totalAmount'))}). "
|
||||||
|
f"כעת ניתן להפיק payload לאימייל למשרד המשפטים באמצעות prepare_monthly_320_email."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return fail(f"שגיאה ביצירת חשבונית חודשית: {e}")
|
||||||
|
|
||||||
|
async def prepare_monthly_320_email(invoiceId: str) -> str:
|
||||||
|
if not invoiceId:
|
||||||
|
return fail("צריך מזהה חשבונית 320 חודשית")
|
||||||
|
try:
|
||||||
|
result = await crm.get(f"Invoice/{invoiceId}/action/prepareMonthly320Email")
|
||||||
|
payload = result.get("emailPayload") or result
|
||||||
|
return ok(json.dumps(payload, ensure_ascii=False))
|
||||||
|
except Exception as e:
|
||||||
|
return fail(f"שגיאה בהכנת payload לאימייל: {e}")
|
||||||
|
|
||||||
|
async def resubmit_charge(chargeId: str, newActivitySubType: str = "") -> str:
|
||||||
|
if not chargeId:
|
||||||
|
return fail("צריך מזהה חיוב")
|
||||||
|
body = {"id": chargeId}
|
||||||
|
if newActivitySubType:
|
||||||
|
body["newActivitySubType"] = newActivitySubType
|
||||||
|
try:
|
||||||
|
result = await crm.post("Charge/action/resubmit", body)
|
||||||
|
if not result.get("success"):
|
||||||
|
return fail("הגשה מחדש נכשלה")
|
||||||
|
return ok(
|
||||||
|
f"חיוב הוגש מחדש: ID חדש {result.get('newChargeId')} "
|
||||||
|
f"(מס׳ {result.get('newChargeNumber')}), "
|
||||||
|
f"סכום {_money(result.get('newChargeAmount'))}, "
|
||||||
|
f"סוג פעילות {result.get('newChargeActivitySubType')}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return fail(f"שגיאה בהגשה מחדש: {e}")
|
||||||
|
|
||||||
|
tools["create_legal_aid_proforma"] = {
|
||||||
|
"description": (
|
||||||
|
"Create a legal-aid Proforma (Invoice type 400, חשבון עסקה) bundling Recorded charges. "
|
||||||
|
"All chargeIds must be legal-aid charges in 'Recorded' status. "
|
||||||
|
"After this, the secretary submits the proforma manually at the official Legal Aid site and gets a request number — "
|
||||||
|
"then call mark_legal_aid_submitted with that number."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chargeIds": {"type": "array", "items": {"type": "string"}, "description": "Legal-aid charge IDs (status=Recorded)"},
|
||||||
|
},
|
||||||
|
"required": ["chargeIds"],
|
||||||
|
},
|
||||||
|
"handler": create_legal_aid_proforma,
|
||||||
|
}
|
||||||
|
|
||||||
|
tools["mark_legal_aid_submitted"] = {
|
||||||
|
"description": (
|
||||||
|
"Mark a legal-aid Proforma as submitted to the official Legal Aid website. "
|
||||||
|
"Requires the legalAidRequestNumber returned by the official site (format like '2026-04-00123'). "
|
||||||
|
"Flips all linked charges from Recorded to Submitted. Do NOT call without an explicit number from the user."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"invoiceId": {"type": "string", "description": "Proforma Invoice ID (documentType=400)"},
|
||||||
|
"legalAidRequestNumber": {"type": "string", "description": "Request number from official Legal Aid site"},
|
||||||
|
},
|
||||||
|
"required": ["invoiceId", "legalAidRequestNumber"],
|
||||||
|
},
|
||||||
|
"handler": mark_legal_aid_submitted,
|
||||||
|
}
|
||||||
|
|
||||||
|
tools["legal_aid_monthly_summary"] = {
|
||||||
|
"description": "Preview what a monthly 320 legal-aid tax invoice would contain for a given YYYY-MM. Read-only.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"month": {"type": "string", "description": "Month in YYYY-MM format (e.g. 2026-04)"},
|
||||||
|
},
|
||||||
|
"required": ["month"],
|
||||||
|
},
|
||||||
|
"handler": legal_aid_monthly_summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
tools["create_monthly_legal_aid_invoice"] = {
|
||||||
|
"description": (
|
||||||
|
"Create the monthly type-320 tax invoice that aggregates all Paid legal-aid charges for the given YYYY-MM. "
|
||||||
|
"Flips all aggregated charges from Paid to Billed (terminal). "
|
||||||
|
"Use legal_aid_monthly_summary first to preview before creating."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"month": {"type": "string", "description": "Month in YYYY-MM format (e.g. 2026-04)"},
|
||||||
|
},
|
||||||
|
"required": ["month"],
|
||||||
|
},
|
||||||
|
"handler": create_monthly_legal_aid_invoice,
|
||||||
|
}
|
||||||
|
|
||||||
|
tools["prepare_monthly_320_email"] = {
|
||||||
|
"description": (
|
||||||
|
"Build the email payload (recipient, subject, body, attachment ref) for sending a monthly 320 legal-aid invoice "
|
||||||
|
"to the Justice Ministry. Returns JSON — does NOT actually send. The secretary copy-pastes or pipes via n8n."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"invoiceId": {"type": "string", "description": "Monthly 320 Invoice ID"},
|
||||||
|
},
|
||||||
|
"required": ["invoiceId"],
|
||||||
|
},
|
||||||
|
"handler": prepare_monthly_320_email,
|
||||||
|
}
|
||||||
|
|
||||||
|
tools["resubmit_charge"] = {
|
||||||
|
"description": (
|
||||||
|
"Clone a Rejected legal-aid charge as a new Recorded charge, optionally with a corrected activitySubType. "
|
||||||
|
"Use after the Legal Aid office rejects a line in a payment report."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chargeId": {"type": "string", "description": "ID of the Rejected charge to clone"},
|
||||||
|
"newActivitySubType": {"type": "string", "description": "Corrected activity sub-type code (optional)"},
|
||||||
|
},
|
||||||
|
"required": ["chargeId"],
|
||||||
|
},
|
||||||
|
"handler": resubmit_charge,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user