mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
Dealix Deal Exchange OS core (3,271 lines): - company_twin.py (792 lines): Capabilities graph, needs graph, authority matrix, red lines, approved claims, identity modes (transparent_ai/delegated/shadow) - deal_taxonomy.py (573 lines): 15 deal types (barter, referral, co-sell, co-market, subcontract, white-label, reseller, alliance, channel, JV, acquisition, investment, vendor replacement, capability gap, tender consortium) with Arabic templates - deal_room.py (674 lines): Central deal workspace with hypothesis, mutual value, BATNA, concession tracking, approval center, audit log, stage management - operating_modes.py (429 lines): 5 modes (manual→draft→assisted→negotiation→strategic) with per-mode policies, channel permissions, commitment limits, escalation triggers - channel_compliance.py (803 lines): Email (SPF/DKIM/unsubscribe), WhatsApp (opt-in/24h/templates), LinkedIn (assist-mode ONLY), consent ledger (immutable), channel health monitoring - Updated __init__.py with all new exports https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
675 lines
25 KiB
Python
675 lines
25 KiB
Python
"""
|
|
Deal Room — Central workspace for managing an active B2B deal through all stages.
|
|
غرفة الصفقة: مساحة العمل المركزية لإدارة صفقة B2B عبر جميع المراحل
|
|
"""
|
|
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.strategic_deal import StrategicDeal, CompanyProfile, DealStatus
|
|
from app.services.llm.provider import get_llm
|
|
|
|
logger = logging.getLogger("dealix.strategic_deals.deal_room")
|
|
|
|
|
|
# ── Room Stages ─────────────────────────────────────────────────────────────
|
|
|
|
ROOM_STAGES = [
|
|
"discovery",
|
|
"qualification",
|
|
"proposal",
|
|
"negotiation",
|
|
"legal",
|
|
"approval",
|
|
"closed_won",
|
|
"closed_lost",
|
|
]
|
|
|
|
STAGE_LABELS_AR = {
|
|
"discovery": "اكتشاف",
|
|
"qualification": "تأهيل",
|
|
"proposal": "مقترح",
|
|
"negotiation": "تفاوض",
|
|
"legal": "مراجعة قانونية",
|
|
"approval": "موافقة",
|
|
"closed_won": "تمت بنجاح",
|
|
"closed_lost": "لم تتم",
|
|
}
|
|
|
|
|
|
# ── Pydantic Models ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class ConcessionRecord(BaseModel):
|
|
"""Record of a single concession given or received."""
|
|
what: str
|
|
value_sar: float = 0.0
|
|
direction: str = "given" # given or received
|
|
timestamp: str = ""
|
|
rationale: str = ""
|
|
|
|
|
|
class ApprovalRequest(BaseModel):
|
|
"""An approval request within a deal room."""
|
|
approval_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
action: str
|
|
details: str = ""
|
|
requested_by: str = "ai_agent"
|
|
requested_at: str = ""
|
|
status: str = "pending" # pending, granted, denied
|
|
decided_by: str = ""
|
|
decided_at: str = ""
|
|
notes: str = ""
|
|
|
|
|
|
class AuditEntry(BaseModel):
|
|
"""Immutable audit log entry."""
|
|
timestamp: str
|
|
actor: str # user_id or "ai_agent"
|
|
action: str
|
|
details: str = ""
|
|
metadata: dict = Field(default_factory=dict)
|
|
|
|
|
|
class RoomMessage(BaseModel):
|
|
"""A message within the deal room conversation."""
|
|
message_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
direction: str # inbound or outbound
|
|
channel: str # email, whatsapp, internal
|
|
content: str
|
|
sender: str = ""
|
|
timestamp: str = ""
|
|
metadata: dict = Field(default_factory=dict)
|
|
|
|
|
|
class DealRoom(BaseModel):
|
|
"""
|
|
Central workspace for managing a B2B deal.
|
|
غرفة الصفقة: مساحة العمل المركزية لصفقة B2B
|
|
"""
|
|
room_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
deal_id: str
|
|
tenant_id: str
|
|
|
|
# Parties
|
|
our_twin_id: str = ""
|
|
their_profile: dict = Field(default_factory=dict)
|
|
|
|
# Deal context
|
|
deal_type: str = ""
|
|
hypothesis: str = "" # Why this deal makes sense (Arabic)
|
|
mutual_value: dict = Field(
|
|
default_factory=lambda: {"us": [], "them": []},
|
|
)
|
|
|
|
# Negotiation state
|
|
current_offer: dict = Field(default_factory=dict)
|
|
their_last_response: dict = Field(default_factory=dict)
|
|
concessions_made: list[ConcessionRecord] = Field(default_factory=list)
|
|
concessions_received: list[ConcessionRecord] = Field(default_factory=list)
|
|
batna: dict = Field(default_factory=dict) # Best alternative if deal fails
|
|
walk_away_threshold: dict = Field(default_factory=dict)
|
|
|
|
# Conversation
|
|
messages: list[RoomMessage] = Field(default_factory=list)
|
|
channel: str = "email"
|
|
|
|
# Status
|
|
stage: str = "discovery"
|
|
blockers: list[str] = Field(default_factory=list)
|
|
next_action: str = ""
|
|
next_action_ar: str = ""
|
|
|
|
# Governance
|
|
approvals_pending: list[ApprovalRequest] = Field(default_factory=list)
|
|
approvals_granted: list[ApprovalRequest] = Field(default_factory=list)
|
|
red_line_violations: list[dict] = Field(default_factory=list)
|
|
audit_log: list[AuditEntry] = Field(default_factory=list)
|
|
|
|
# Metadata
|
|
created_at: str = ""
|
|
updated_at: str = ""
|
|
|
|
|
|
# ── Service ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class DealRoomService:
|
|
"""
|
|
Manages DealRoom lifecycle: creation, stage transitions, messaging, governance.
|
|
إدارة دورة حياة غرفة الصفقة: الإنشاء، والانتقال بين المراحل، والرسائل، والحوكمة
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.llm = get_llm()
|
|
|
|
# ── Create Room ─────────────────────────────────────────────────────────
|
|
|
|
async def create_room(
|
|
self,
|
|
deal_id: str,
|
|
our_twin_id: str,
|
|
their_profile: dict,
|
|
db: AsyncSession,
|
|
) -> DealRoom:
|
|
"""
|
|
Create a new deal room linked to a StrategicDeal.
|
|
إنشاء غرفة صفقة جديدة مرتبطة بصفقة استراتيجية
|
|
"""
|
|
deal_result = await db.execute(
|
|
select(StrategicDeal).where(StrategicDeal.id == deal_id)
|
|
)
|
|
deal = deal_result.scalar_one_or_none()
|
|
if not deal:
|
|
raise ValueError(f"الصفقة غير موجودة: {deal_id}")
|
|
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
room = DealRoom(
|
|
deal_id=str(deal.id),
|
|
tenant_id=str(deal.tenant_id),
|
|
our_twin_id=our_twin_id,
|
|
their_profile=their_profile,
|
|
deal_type=deal.deal_type or "",
|
|
channel=deal.channel or "email",
|
|
stage="discovery",
|
|
next_action="research_target",
|
|
next_action_ar="بحث عن الطرف الآخر وتحليل احتياجاته",
|
|
created_at=now_iso,
|
|
updated_at=now_iso,
|
|
audit_log=[
|
|
AuditEntry(
|
|
timestamp=now_iso,
|
|
actor="ai_agent",
|
|
action="room_created",
|
|
details=f"غرفة صفقة جديدة — نوع: {deal.deal_type or 'غير محدد'}",
|
|
),
|
|
],
|
|
)
|
|
|
|
# Persist room data on the deal
|
|
history = list(deal.negotiation_history or [])
|
|
history.append({
|
|
"round": 0,
|
|
"action": "room_created",
|
|
"room_id": room.room_id,
|
|
"timestamp": now_iso,
|
|
})
|
|
deal.negotiation_history = history
|
|
|
|
# Store room in proposed_terms as a nested structure
|
|
existing_terms = dict(deal.proposed_terms or {})
|
|
existing_terms["_deal_room"] = room.model_dump(mode="json")
|
|
deal.proposed_terms = existing_terms
|
|
await db.flush()
|
|
|
|
logger.info("Created deal room %s for deal %s", room.room_id, deal_id)
|
|
return room
|
|
|
|
# ── Load Room ───────────────────────────────────────────────────────────
|
|
|
|
async def _load_room(self, room_id: str, db: AsyncSession) -> tuple[DealRoom, StrategicDeal]:
|
|
"""Load a DealRoom and its parent StrategicDeal."""
|
|
# Scan deals for the room
|
|
all_deals = await db.execute(select(StrategicDeal))
|
|
for deal in all_deals.scalars():
|
|
terms = deal.proposed_terms or {}
|
|
room_data = terms.get("_deal_room")
|
|
if room_data and room_data.get("room_id") == room_id:
|
|
return DealRoom(**room_data), deal
|
|
raise ValueError(f"غرفة الصفقة غير موجودة: {room_id}")
|
|
|
|
async def _persist_room(self, room: DealRoom, deal: StrategicDeal, db: AsyncSession):
|
|
"""Persist the room state back onto the deal."""
|
|
room.updated_at = datetime.now(timezone.utc).isoformat()
|
|
existing_terms = dict(deal.proposed_terms or {})
|
|
existing_terms["_deal_room"] = room.model_dump(mode="json")
|
|
deal.proposed_terms = existing_terms
|
|
await db.flush()
|
|
|
|
# ── Update Stage ────────────────────────────────────────────────────────
|
|
|
|
async def update_stage(
|
|
self,
|
|
room_id: str,
|
|
new_stage: str,
|
|
reason: str,
|
|
db: AsyncSession,
|
|
):
|
|
"""
|
|
Transition the deal room to a new stage with audit logging.
|
|
نقل غرفة الصفقة إلى مرحلة جديدة مع تسجيل في سجل المراجعة
|
|
"""
|
|
if new_stage not in ROOM_STAGES:
|
|
raise ValueError(f"مرحلة غير صالحة: {new_stage}. المراحل المتاحة: {', '.join(ROOM_STAGES)}")
|
|
|
|
room, deal = await self._load_room(room_id, db)
|
|
old_stage = room.stage
|
|
|
|
# Validate forward-only transition (except to closed_lost which can happen from any stage)
|
|
if new_stage != "closed_lost":
|
|
old_idx = ROOM_STAGES.index(old_stage) if old_stage in ROOM_STAGES else 0
|
|
new_idx = ROOM_STAGES.index(new_stage)
|
|
if new_idx < old_idx:
|
|
raise ValueError(
|
|
f"لا يمكن الرجوع من {STAGE_LABELS_AR.get(old_stage, old_stage)} "
|
|
f"إلى {STAGE_LABELS_AR.get(new_stage, new_stage)}"
|
|
)
|
|
|
|
room.stage = new_stage
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
room.audit_log.append(
|
|
AuditEntry(
|
|
timestamp=now_iso,
|
|
actor="ai_agent",
|
|
action="stage_changed",
|
|
details=f"انتقال من {STAGE_LABELS_AR.get(old_stage, old_stage)} إلى {STAGE_LABELS_AR.get(new_stage, new_stage)}: {reason}",
|
|
metadata={"old_stage": old_stage, "new_stage": new_stage},
|
|
)
|
|
)
|
|
|
|
# Sync deal status
|
|
stage_to_status = {
|
|
"discovery": DealStatus.DISCOVERY.value,
|
|
"qualification": DealStatus.DISCOVERY.value,
|
|
"proposal": DealStatus.OUTREACH.value,
|
|
"negotiation": DealStatus.NEGOTIATING.value,
|
|
"legal": DealStatus.TERM_SHEET.value,
|
|
"approval": DealStatus.DUE_DILIGENCE.value,
|
|
"closed_won": DealStatus.CLOSED_WON.value,
|
|
"closed_lost": DealStatus.CLOSED_LOST.value,
|
|
}
|
|
mapped_status = stage_to_status.get(new_stage)
|
|
if mapped_status:
|
|
deal.status = mapped_status
|
|
if new_stage in ("closed_won", "closed_lost"):
|
|
deal.closed_at = datetime.now(timezone.utc)
|
|
|
|
await self._persist_room(room, deal, db)
|
|
logger.info("Room %s stage: %s -> %s (%s)", room_id, old_stage, new_stage, reason)
|
|
|
|
# ── Add Message ─────────────────────────────────────────────────────────
|
|
|
|
async def add_message(
|
|
self,
|
|
room_id: str,
|
|
message: str,
|
|
direction: str,
|
|
channel: str,
|
|
db: AsyncSession,
|
|
):
|
|
"""
|
|
Record a message in the deal room conversation.
|
|
تسجيل رسالة في محادثة غرفة الصفقة
|
|
"""
|
|
room, deal = await self._load_room(room_id, db)
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
|
|
room.messages.append(
|
|
RoomMessage(
|
|
direction=direction,
|
|
channel=channel,
|
|
content=message,
|
|
sender="ai_agent" if direction == "outbound" else "counterparty",
|
|
timestamp=now_iso,
|
|
)
|
|
)
|
|
|
|
if direction == "inbound":
|
|
room.their_last_response = {
|
|
"content": message,
|
|
"channel": channel,
|
|
"timestamp": now_iso,
|
|
}
|
|
|
|
room.audit_log.append(
|
|
AuditEntry(
|
|
timestamp=now_iso,
|
|
actor="ai_agent" if direction == "outbound" else "counterparty",
|
|
action=f"message_{direction}",
|
|
details=message[:200],
|
|
metadata={"channel": channel},
|
|
)
|
|
)
|
|
|
|
await self._persist_room(room, deal, db)
|
|
logger.info("Added %s message to room %s via %s", direction, room_id, channel)
|
|
|
|
# ── Record Concession ───────────────────────────────────────────────────
|
|
|
|
async def record_concession(
|
|
self,
|
|
room_id: str,
|
|
what: str,
|
|
value: float,
|
|
db: AsyncSession,
|
|
):
|
|
"""
|
|
Record a concession made during negotiation.
|
|
تسجيل تنازل تم خلال التفاوض
|
|
"""
|
|
room, deal = await self._load_room(room_id, db)
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
|
|
record = ConcessionRecord(
|
|
what=what,
|
|
value_sar=value,
|
|
direction="given",
|
|
timestamp=now_iso,
|
|
)
|
|
room.concessions_made.append(record)
|
|
|
|
room.audit_log.append(
|
|
AuditEntry(
|
|
timestamp=now_iso,
|
|
actor="ai_agent",
|
|
action="concession_made",
|
|
details=f"تنازل: {what} (قيمة: {value:,.0f} ريال)",
|
|
metadata={"value_sar": value},
|
|
)
|
|
)
|
|
|
|
await self._persist_room(room, deal, db)
|
|
logger.info("Recorded concession in room %s: %s (%.0f SAR)", room_id, what, value)
|
|
|
|
# ── Check Red Lines ─────────────────────────────────────────────────────
|
|
|
|
async def check_red_lines(
|
|
self,
|
|
room_id: str,
|
|
proposed_terms: dict,
|
|
db: AsyncSession,
|
|
) -> list[str]:
|
|
"""
|
|
Check proposed terms against the company's red lines.
|
|
التحقق من الشروط المقترحة مقابل الخطوط الحمراء للشركة
|
|
"""
|
|
room, deal = await self._load_room(room_id, db)
|
|
|
|
# Load the company twin to get red lines
|
|
from app.services.strategic_deals.company_twin import CompanyTwinBuilder
|
|
builder = CompanyTwinBuilder()
|
|
twin = None
|
|
|
|
if room.our_twin_id:
|
|
twin = await builder.get_twin_by_id(room.our_twin_id, db)
|
|
|
|
if not twin:
|
|
# Try loading by company_id from the deal initiator
|
|
if deal.initiator_profile_id:
|
|
twin = await builder.get_twin(str(deal.initiator_profile_id), db)
|
|
|
|
red_lines = twin.red_lines if twin else []
|
|
if not red_lines:
|
|
return []
|
|
|
|
violations: list[str] = []
|
|
terms_text = str(proposed_terms).lower()
|
|
|
|
# Static keyword check
|
|
keyword_checks = {
|
|
"حصرية": "exclusivity",
|
|
"حقوق ملكية": "equity",
|
|
"ضمان": "guarantee",
|
|
"تعويض": "compensation",
|
|
"غرامة": "penalty",
|
|
}
|
|
|
|
for red_line in red_lines:
|
|
red_line_lower = red_line.lower()
|
|
# Direct keyword match
|
|
if red_line_lower in terms_text:
|
|
violations.append(f"خط أحمر: {red_line}")
|
|
continue
|
|
# Check Arabic keywords
|
|
for ar_kw, en_kw in keyword_checks.items():
|
|
if ar_kw in red_line_lower and en_kw in terms_text:
|
|
violations.append(f"خط أحمر: {red_line}")
|
|
break
|
|
|
|
# If there are potential concerns, use LLM for deeper analysis
|
|
if not violations and red_lines:
|
|
system_prompt = """أنت مراجع عقود سعودي. تحقق من الشروط المقترحة مقابل الخطوط الحمراء.
|
|
|
|
أعد النتائج بصيغة JSON:
|
|
{
|
|
"violations": ["وصف الانتهاك 1", "وصف الانتهاك 2"],
|
|
"warnings": ["تحذير 1"]
|
|
}
|
|
|
|
إذا لم يكن هناك انتهاكات، أعد قوائم فارغة."""
|
|
|
|
context = (
|
|
f"الخطوط الحمراء:\n" + "\n".join(f"- {rl}" for rl in red_lines)
|
|
+ f"\n\nالشروط المقترحة:\n{str(proposed_terms)}"
|
|
)
|
|
|
|
try:
|
|
llm_response = await self.llm.complete(
|
|
system_prompt=system_prompt,
|
|
user_message=context,
|
|
json_mode=True,
|
|
temperature=0.1,
|
|
)
|
|
result = llm_response.parse_json()
|
|
if result and result.get("violations"):
|
|
violations.extend(result["violations"])
|
|
except Exception as exc:
|
|
logger.warning("LLM red-line check failed: %s", exc)
|
|
|
|
# Record violations
|
|
if violations:
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
for v in violations:
|
|
room.red_line_violations.append({
|
|
"violation": v,
|
|
"proposed_terms": proposed_terms,
|
|
"timestamp": now_iso,
|
|
})
|
|
room.audit_log.append(
|
|
AuditEntry(
|
|
timestamp=now_iso,
|
|
actor="ai_agent",
|
|
action="red_line_violation",
|
|
details=f"تم اكتشاف {len(violations)} انتهاك للخطوط الحمراء",
|
|
metadata={"violations": violations},
|
|
)
|
|
)
|
|
await self._persist_room(room, deal, db)
|
|
|
|
logger.info("Red line check for room %s: %d violations", room_id, len(violations))
|
|
return violations
|
|
|
|
# ── Request Approval ────────────────────────────────────────────────────
|
|
|
|
async def request_approval(
|
|
self,
|
|
room_id: str,
|
|
action: str,
|
|
details: str,
|
|
db: AsyncSession,
|
|
) -> str:
|
|
"""
|
|
Create an approval request that pauses AI action until a human decides.
|
|
إنشاء طلب موافقة يوقف عمل الذكاء الاصطناعي حتى يقرر إنسان
|
|
"""
|
|
room, deal = await self._load_room(room_id, db)
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
|
|
approval = ApprovalRequest(
|
|
action=action,
|
|
details=details,
|
|
requested_at=now_iso,
|
|
)
|
|
room.approvals_pending.append(approval)
|
|
|
|
room.blockers.append(f"بانتظار موافقة على: {action}")
|
|
room.audit_log.append(
|
|
AuditEntry(
|
|
timestamp=now_iso,
|
|
actor="ai_agent",
|
|
action="approval_requested",
|
|
details=f"طلب موافقة: {action} — {details}",
|
|
metadata={"approval_id": approval.approval_id},
|
|
)
|
|
)
|
|
|
|
await self._persist_room(room, deal, db)
|
|
logger.info("Approval requested in room %s: %s (id=%s)", room_id, action, approval.approval_id)
|
|
return approval.approval_id
|
|
|
|
# ── Grant Approval ──────────────────────────────────────────────────────
|
|
|
|
async def grant_approval(
|
|
self,
|
|
room_id: str,
|
|
approval_id: str,
|
|
user_id: str,
|
|
db: AsyncSession,
|
|
):
|
|
"""
|
|
Grant a pending approval request.
|
|
منح موافقة على طلب معلق
|
|
"""
|
|
room, deal = await self._load_room(room_id, db)
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
|
|
granted = None
|
|
remaining_pending = []
|
|
for req in room.approvals_pending:
|
|
if req.approval_id == approval_id:
|
|
req.status = "granted"
|
|
req.decided_by = user_id
|
|
req.decided_at = now_iso
|
|
granted = req
|
|
else:
|
|
remaining_pending.append(req)
|
|
|
|
if not granted:
|
|
raise ValueError(f"طلب الموافقة غير موجود: {approval_id}")
|
|
|
|
room.approvals_pending = remaining_pending
|
|
room.approvals_granted.append(granted)
|
|
|
|
# Remove related blocker
|
|
blocker_prefix = f"بانتظار موافقة على: {granted.action}"
|
|
room.blockers = [b for b in room.blockers if b != blocker_prefix]
|
|
|
|
room.audit_log.append(
|
|
AuditEntry(
|
|
timestamp=now_iso,
|
|
actor=user_id,
|
|
action="approval_granted",
|
|
details=f"تمت الموافقة على: {granted.action}",
|
|
metadata={"approval_id": approval_id},
|
|
)
|
|
)
|
|
|
|
await self._persist_room(room, deal, db)
|
|
logger.info("Approval %s granted by %s in room %s", approval_id, user_id, room_id)
|
|
|
|
# ── Deal Summary ────────────────────────────────────────────────────────
|
|
|
|
async def get_deal_summary(
|
|
self,
|
|
room_id: str,
|
|
db: AsyncSession,
|
|
) -> dict:
|
|
"""
|
|
Generate an Arabic summary of the deal room status.
|
|
إنشاء ملخص عربي لحالة غرفة الصفقة
|
|
"""
|
|
room, deal = await self._load_room(room_id, db)
|
|
|
|
# Gather data for LLM summary
|
|
msg_count = len(room.messages)
|
|
inbound = sum(1 for m in room.messages if m.direction == "inbound")
|
|
outbound = msg_count - inbound
|
|
concessions_given = len(room.concessions_made)
|
|
concessions_got = len(room.concessions_received)
|
|
total_concession_value = sum(c.value_sar for c in room.concessions_made)
|
|
pending_approvals = len(room.approvals_pending)
|
|
violations = len(room.red_line_violations)
|
|
|
|
stage_ar = STAGE_LABELS_AR.get(room.stage, room.stage)
|
|
their_name = room.their_profile.get("company_name", room.their_profile.get("name", "الطرف الآخر"))
|
|
|
|
summary = {
|
|
"room_id": room.room_id,
|
|
"deal_id": room.deal_id,
|
|
"stage": room.stage,
|
|
"stage_ar": stage_ar,
|
|
"their_name": their_name,
|
|
"deal_type": room.deal_type,
|
|
"channel": room.channel,
|
|
"statistics": {
|
|
"total_messages": msg_count,
|
|
"inbound_messages": inbound,
|
|
"outbound_messages": outbound,
|
|
"concessions_given": concessions_given,
|
|
"concessions_received": concessions_got,
|
|
"total_concession_value_sar": total_concession_value,
|
|
"pending_approvals": pending_approvals,
|
|
"red_line_violations": violations,
|
|
},
|
|
"blockers": room.blockers,
|
|
"next_action": room.next_action,
|
|
"next_action_ar": room.next_action_ar,
|
|
"current_offer": room.current_offer,
|
|
"their_last_response": room.their_last_response,
|
|
"summary_ar": (
|
|
f"صفقة مع {their_name} — المرحلة: {stage_ar}\n"
|
|
f"عدد الرسائل: {msg_count} ({inbound} واردة، {outbound} صادرة)\n"
|
|
f"التنازلات المقدمة: {concessions_given} (بقيمة {total_concession_value:,.0f} ريال)\n"
|
|
+ (f"موافقات معلقة: {pending_approvals}\n" if pending_approvals else "")
|
|
+ (f"تحذير: {violations} انتهاك للخطوط الحمراء\n" if violations else "")
|
|
+ (f"الخطوة التالية: {room.next_action_ar}" if room.next_action_ar else "")
|
|
),
|
|
}
|
|
|
|
logger.info("Generated deal summary for room %s", room_id)
|
|
return summary
|
|
|
|
# ── Get Rooms ───────────────────────────────────────────────────────────
|
|
|
|
async def get_rooms(
|
|
self,
|
|
tenant_id: str,
|
|
stage: Optional[str] = None,
|
|
db: AsyncSession = None,
|
|
) -> list[DealRoom]:
|
|
"""
|
|
List all deal rooms for a tenant, optionally filtered by stage.
|
|
عرض جميع غرف الصفقات لمستأجر معين مع إمكانية الفلترة بالمرحلة
|
|
"""
|
|
query = select(StrategicDeal).where(
|
|
StrategicDeal.tenant_id == tenant_id
|
|
)
|
|
result = await db.execute(query)
|
|
deals = result.scalars().all()
|
|
|
|
rooms: list[DealRoom] = []
|
|
for deal in deals:
|
|
terms = deal.proposed_terms or {}
|
|
room_data = terms.get("_deal_room")
|
|
if not room_data:
|
|
continue
|
|
try:
|
|
room = DealRoom(**room_data)
|
|
if stage and room.stage != stage:
|
|
continue
|
|
rooms.append(room)
|
|
except Exception as exc:
|
|
logger.warning("Failed to deserialize room from deal %s: %s", deal.id, exc)
|
|
|
|
logger.info(
|
|
"Retrieved %d deal rooms for tenant %s (stage=%s)",
|
|
len(rooms), tenant_id, stage or "all",
|
|
)
|
|
return rooms
|