import uuid from datetime import timedelta from django.conf import settings from django.db import models from django.utils import timezone def default_ticket_draft_expiry(): return timezone.now() + timedelta(hours=24) class TicketStatus(models.TextChoices): PENDING_INFO = "pending_info", "Pending info" PENDING = "pending", "Pending" IN_PROGRESS = "in_progress", "In progress" PENDING_CONFIRM = "pending_confirm", "Pending confirm" RESOLVED = "resolved", "Resolved" CLOSED = "closed", "Closed" class TicketPriority(models.TextChoices): LOW = "low", "Low" MEDIUM = "medium", "Medium" HIGH = "high", "High" URGENT = "urgent", "Urgent" class TicketActorSource(models.TextChoices): HUMAN = "human", "Human" AGENT = "agent", "Agent" CHANNEL = "channel", "Channel" SYSTEM = "system", "System" class TicketCollaboratorRole(models.TextChoices): COLLABORATOR = "collaborator", "Collaborator" WATCHER = "watcher", "Watcher" class TicketCategory(models.Model): parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL) name = models.CharField(max_length=120) code = models.CharField(max_length=80, unique=True) description = models.TextField(blank=True) enabled = models.BooleanField(default=True) sort_order = models.PositiveIntegerField(default=0) class Meta: ordering = ["sort_order", "name"] def __str__(self) -> str: return self.name class Ticket(models.Model): ticket_no = models.CharField(max_length=32, unique=True) title = models.CharField(max_length=200) description = models.TextField() category = models.ForeignKey(TicketCategory, null=True, blank=True, on_delete=models.SET_NULL) status = models.CharField( max_length=32, choices=TicketStatus.choices, default=TicketStatus.PENDING, ) priority = models.CharField( max_length=20, choices=TicketPriority.choices, default=TicketPriority.MEDIUM, ) problem_performance = models.TextField(blank=True) problem_object = models.CharField(max_length=160, blank=True) submitter_name = models.CharField(max_length=80, blank=True) submitter_org = models.CharField(max_length=160, blank=True) replied_to_customer = models.BooleanField(default=False) impact_scope = models.CharField(max_length=80, blank=True) environment = models.CharField(max_length=80, blank=True) related_system = models.CharField(max_length=120, blank=True) product_name = models.CharField(max_length=120, blank=True) module_name = models.CharField(max_length=120, blank=True) tags = models.JSONField(default=list, blank=True) attempted_actions = models.TextField(blank=True) error_information = models.TextField(blank=True) customer_request = models.TextField(blank=True) current_result = models.TextField(blank=True) problem_started_at = models.DateTimeField(null=True, blank=True) customer_last_message_at = models.DateTimeField(null=True, blank=True) support_first_response_at = models.DateTimeField(null=True, blank=True) support_last_reply_at = models.DateTimeField(null=True, blank=True) requester = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="requested_tickets", ) assignee = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="assigned_tickets", ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="created_tickets", ) follow_up_of = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="follow_up_tickets", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) resolved_at = models.DateTimeField(null=True, blank=True) closed_at = models.DateTimeField(null=True, blank=True) archived_at = models.DateTimeField(null=True, blank=True) archived_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="archived_tickets", ) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["ticket_no"]), models.Index(fields=["status", "priority"]), models.Index(fields=["created_at"]), models.Index(fields=["archived_at"]), ] def __str__(self) -> str: return f"{self.ticket_no} {self.title}" class TicketCollaborator(models.Model): ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="collaborators") user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ticket_collaborations", ) role = models.CharField( max_length=24, choices=TicketCollaboratorRole.choices, default=TicketCollaboratorRole.COLLABORATOR, ) added_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="added_ticket_collaborators", ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["created_at"] constraints = [ models.UniqueConstraint( fields=["ticket", "user", "role"], name="unique_ticket_user_collaborator_role", ) ] class TicketFieldEvidence(models.Model): ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="field_evidence") field_name = models.CharField(max_length=80) value = models.JSONField(null=True, blank=True) source = models.CharField( max_length=24, choices=TicketActorSource.choices, default=TicketActorSource.HUMAN, ) confidence = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True) evidence = models.JSONField(default=dict, blank=True) agent_run_id = models.BigIntegerField(null=True, blank=True) is_protected = models.BooleanField(default=False) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="ticket_field_evidence", ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["field_name", "-created_at"] indexes = [ models.Index(fields=["ticket", "field_name", "created_at"]), models.Index(fields=["source", "agent_run_id"]), ] class TicketSimilaritySuggestion(models.Model): source_ticket = models.ForeignKey( Ticket, on_delete=models.CASCADE, related_name="similarity_suggestions", ) candidate_ticket = models.ForeignKey( Ticket, on_delete=models.CASCADE, related_name="suggested_for_tickets", ) score = models.DecimalField(max_digits=6, decimal_places=5) title_similarity = models.DecimalField(max_digits=6, decimal_places=5, default=0) keyword_score = models.DecimalField(max_digits=6, decimal_places=5, default=0) matched_excerpt = models.TextField(blank=True) algorithm_version = models.CharField(max_length=40, default="fts_trigram_v2_context") created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-score", "candidate_ticket_id"] constraints = [ models.UniqueConstraint( fields=["source_ticket", "candidate_ticket", "algorithm_version"], name="unique_ticket_similarity_suggestion", ), models.CheckConstraint( condition=~models.Q(source_ticket=models.F("candidate_ticket")), name="ticket_similarity_not_self", ), ] indexes = [models.Index(fields=["source_ticket", "algorithm_version", "score"])] class TicketComment(models.Model): ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments") content = models.TextField() comment_type = models.CharField(max_length=40, default="user") visibility = models.CharField(max_length=40, default="internal") created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["created_at"] class TicketAttachment(models.Model): ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="attachments") file_name = models.CharField(max_length=255) file_type = models.CharField(max_length=80, blank=True) mime_type = models.CharField(max_length=120, blank=True) file_size = models.PositiveBigIntegerField(default=0) storage_path = models.CharField(max_length=500) sha256 = models.CharField(max_length=64, blank=True) scan_status = models.CharField(max_length=24, default="pending") scan_result = models.JSONField(default=dict, blank=True) parse_status = models.CharField(max_length=24, default="pending") parse_error = models.TextField(blank=True) ocr_text = models.TextField(blank=True) parsed_text = models.TextField(blank=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, ) created_at = models.DateTimeField(auto_now_add=True) class TicketDraft(models.Model): public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ticket_drafts", ) status = models.CharField(max_length=24, default="collecting") revision = models.PositiveIntegerField(default=1) source_text = models.TextField(blank=True) problem_valid = models.BooleanField(null=True, blank=True) invalid_reason = models.TextField(blank=True) current_fields = models.JSONField(default=dict) last_ai_fields = models.JSONField(default=dict) field_evidence = models.JSONField(default=dict) message_timeline = models.JSONField(default=list) missing_fields = models.JSONField(default=list) questions = models.JSONField(default=list) blocking_missing_fields = models.JSONField(default=list) blocking_questions = models.JSONField(default=list) guided_rounds = models.PositiveSmallIntegerField(default=0) latest_agent_run = models.ForeignKey( "agents.AgentRun", null=True, blank=True, on_delete=models.SET_NULL, related_name="ticket_drafts", ) latest_artifact = models.ForeignKey( "agents.AgentArtifact", null=True, blank=True, on_delete=models.SET_NULL, related_name="ticket_drafts", ) confirmed_ticket = models.OneToOneField( Ticket, null=True, blank=True, on_delete=models.PROTECT, related_name="source_draft", ) decision = models.CharField(max_length=24, blank=True) error_code = models.CharField(max_length=80, blank=True) error_message = models.TextField(blank=True) expires_at = models.DateTimeField(default=default_ticket_draft_expiry) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) confirmed_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-updated_at"] indexes = [ models.Index(fields=["created_by", "status", "updated_at"]), models.Index(fields=["status", "expires_at"]), ] class TicketDraftMessage(models.Model): draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="messages") role = models.CharField(max_length=24) content = models.TextField() round_number = models.PositiveSmallIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["created_at"] class TicketMessageTimelineItem(models.Model): draft = models.ForeignKey( TicketDraft, on_delete=models.CASCADE, related_name="timeline_items", ) ticket = models.ForeignKey( Ticket, null=True, blank=True, on_delete=models.CASCADE, related_name="message_timeline_items", ) source_material = models.ForeignKey( "TicketDraftMaterial", null=True, blank=True, on_delete=models.SET_NULL, related_name="message_timeline_items", ) sequence = models.PositiveIntegerField() speaker = models.CharField(max_length=120, blank=True) role = models.CharField(max_length=24, default="unknown") content = models.TextField() occurred_at = models.DateTimeField(null=True, blank=True) occurred_at_raw = models.CharField(max_length=120, blank=True) timezone = models.CharField(max_length=80, blank=True) source_ref = models.CharField(max_length=160, blank=True) confidence = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True) conflict = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["sequence"] constraints = [ models.UniqueConstraint( fields=["draft", "sequence"], name="unique_ticket_draft_timeline_sequence", ) ] indexes = [ models.Index(fields=["ticket", "sequence"]), models.Index(fields=["draft", "sequence"]), ] class TicketDraftMaterial(models.Model): public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="materials") file_name = models.CharField(max_length=255) material_type = models.CharField(max_length=24, default="image") extension = models.CharField(max_length=16, blank=True) claimed_mime_type = models.CharField(max_length=120, blank=True) mime_type = models.CharField(max_length=120) file_size = models.PositiveBigIntegerField() sha256 = models.CharField(max_length=64) storage_path = models.CharField(max_length=500) status = models.CharField(max_length=24, default="temporary") scan_status = models.CharField(max_length=24, default="pending") scan_result = models.JSONField(default=dict, blank=True) parse_status = models.CharField(max_length=24, default="pending") parse_error = models.TextField(blank=True) parsed_text = models.TextField(blank=True) confirmed_attachment = models.OneToOneField( TicketAttachment, null=True, blank=True, on_delete=models.SET_NULL, related_name="source_draft_material", ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["created_at"] class IntakeEvaluationCase(models.Model): key = models.SlugField(max_length=120, unique=True) name = models.CharField(max_length=200) source_kind = models.CharField(max_length=32, default="synthetic") valid_input = models.BooleanField(default=True) input_manifest = models.JSONField(default=dict) expected_fields = models.JSONField(default=dict) expected_timeline = models.JSONField(default=list) expected_auto_created = models.BooleanField(default=True) expected_similar_ticket = models.BooleanField(default=False) notes = models.TextField(blank=True) enabled = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["key"] indexes = [models.Index(fields=["source_kind", "enabled"])] class IntakeEvaluationResult(models.Model): case = models.ForeignKey( IntakeEvaluationCase, on_delete=models.PROTECT, related_name="results", ) draft = models.ForeignKey( TicketDraft, on_delete=models.CASCADE, related_name="evaluation_results", ) ticket = models.ForeignKey( Ticket, null=True, blank=True, on_delete=models.SET_NULL, related_name="intake_evaluation_results", ) actual_fields = models.JSONField(default=dict) actual_auto_created = models.BooleanField(default=False) correct_field_count = models.PositiveIntegerField(default=0) evaluated_field_count = models.PositiveIntegerField(default=0) field_accuracy = models.DecimalField(max_digits=6, decimal_places=5, null=True, blank=True) passed = models.BooleanField(default=False) details = models.JSONField(default=dict, blank=True) reviewed_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="reviewed_intake_evaluations", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-updated_at"] constraints = [ models.UniqueConstraint( fields=["case", "draft"], name="unique_intake_case_draft_result", ) ] indexes = [models.Index(fields=["case", "updated_at"])] class SupportEvaluationCase(models.Model): key = models.SlugField(max_length=120, unique=True) name = models.CharField(max_length=200) source_kind = models.CharField(max_length=32, default="synthetic") input_manifest = models.JSONField(default=dict) expected_solution_ids = models.JSONField(default=list) expected_auto_resolve = models.BooleanField(default=False) expected_manual_fallback = models.BooleanField(default=True) notes = models.TextField(blank=True) enabled = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["key"] indexes = [models.Index(fields=["source_kind", "enabled"])] class SupportEvaluationResult(models.Model): case = models.ForeignKey( SupportEvaluationCase, on_delete=models.PROTECT, related_name="results", ) analysis = models.ForeignKey( "tickets.TicketSupportAnalysis", on_delete=models.CASCADE, related_name="evaluation_results", ) actual_candidate_solution_ids = models.JSONField(default=list) actual_citation_ranks = models.JSONField(default=list) matched_expected_solution = models.BooleanField(default=False) actual_auto_resolved = models.BooleanField(default=False) actual_manual_fallback = models.BooleanField(default=False) incorrect_auto_reuse = models.BooleanField(default=False) citations_valid = models.BooleanField(default=False) passed = models.BooleanField(default=False) details = models.JSONField(default=dict, blank=True) reviewed_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="reviewed_support_evaluations", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-updated_at"] constraints = [ models.UniqueConstraint( fields=["case", "analysis"], name="unique_support_case_analysis_result", ) ] indexes = [models.Index(fields=["case", "updated_at"])] class TicketSupportAnalysis(models.Model): ticket = models.ForeignKey( Ticket, on_delete=models.CASCADE, related_name="support_analyses", ) run = models.OneToOneField( "agents.AgentRun", on_delete=models.PROTECT, related_name="ticket_support_analysis", ) query = models.TextField() references = models.JSONField(default=list) retrieval_policy = models.JSONField(default=dict) result_mode = models.CharField(max_length=24, default="trusted") automation_decision = models.JSONField(default=dict, blank=True) applied_solution = models.ForeignKey( "solutions.Solution", null=True, blank=True, on_delete=models.SET_NULL, related_name="applied_support_analyses", ) processed_at = models.DateTimeField(null=True, blank=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="created_ticket_support_analyses", ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] indexes = [models.Index(fields=["ticket", "created_at"])] class CustomerReplyTask(models.Model): ticket = models.ForeignKey( Ticket, on_delete=models.CASCADE, related_name="customer_reply_tasks", ) support_analysis = models.ForeignKey( TicketSupportAnalysis, null=True, blank=True, on_delete=models.SET_NULL, related_name="customer_reply_tasks", ) status = models.CharField(max_length=24, default="draft") draft_text = models.TextField() final_text = models.TextField() revision = models.PositiveIntegerField(default=1) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="created_customer_reply_tasks", ) updated_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="updated_customer_reply_tasks", ) approved_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="approved_customer_reply_tasks", ) approved_at = models.DateTimeField(null=True, blank=True) sent_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="sent_customer_reply_tasks", ) sent_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-created_at"] constraints = [ models.UniqueConstraint( fields=["ticket"], condition=models.Q(status__in=["draft", "approved"]), name="unique_active_customer_reply_task", ) ] indexes = [ models.Index(fields=["ticket", "status", "created_at"]), models.Index(fields=["status", "updated_at"]), ] class CustomerReplyRevision(models.Model): task = models.ForeignKey( CustomerReplyTask, on_delete=models.CASCADE, related_name="revisions", ) version = models.PositiveIntegerField() content = models.TextField() source = models.CharField(max_length=24, default=TicketActorSource.HUMAN) edited_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="customer_reply_revisions", ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["version"] constraints = [ models.UniqueConstraint( fields=["task", "version"], name="unique_customer_reply_revision", ) ] class TicketAutomationRunStatus(models.TextChoices): QUEUED = "queued", "Queued" RUNNING = "running", "Running" WAITING_INPUT = "waiting_input", "Waiting input" WAITING_APPROVAL = "waiting_approval", "Waiting approval" WAITING_DELIVERY = "waiting_delivery", "Waiting delivery" WAITING_CUSTOMER = "waiting_customer", "Waiting customer" COMPLETED = "completed", "Completed" FAILED = "failed", "Failed" CANCELLED = "cancelled", "Cancelled" class TicketAutomationRun(models.Model): """工单端到端自动处理的持久化编排状态。""" public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) ticket = models.ForeignKey( Ticket, on_delete=models.CASCADE, related_name="automation_runs", ) status = models.CharField( max_length=32, choices=TicketAutomationRunStatus.choices, default=TicketAutomationRunStatus.QUEUED, ) current_step = models.CharField(max_length=48, default="intake") policy_snapshot = models.JSONField(default=dict, blank=True) blocking_missing_fields = models.JSONField(default=list, blank=True) blocking_questions = models.JSONField(default=list, blank=True) retry_count = models.PositiveSmallIntegerField(default=0) last_error_code = models.CharField(max_length=80, blank=True) last_error_message = models.TextField(blank=True) next_action_at = models.DateTimeField(null=True, blank=True) support_analysis = models.ForeignKey( TicketSupportAnalysis, null=True, blank=True, on_delete=models.SET_NULL, related_name="automation_runs", ) reply_task = models.ForeignKey( CustomerReplyTask, null=True, blank=True, on_delete=models.SET_NULL, related_name="automation_runs", ) initiated_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="initiated_ticket_automation_runs", ) approved_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="approved_ticket_automation_runs", ) approved_at = models.DateTimeField(null=True, blank=True) started_at = models.DateTimeField(null=True, blank=True) finished_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-created_at"] constraints = [ models.UniqueConstraint( fields=["ticket"], condition=models.Q( status__in=[ TicketAutomationRunStatus.QUEUED, TicketAutomationRunStatus.RUNNING, TicketAutomationRunStatus.WAITING_INPUT, TicketAutomationRunStatus.WAITING_APPROVAL, TicketAutomationRunStatus.WAITING_DELIVERY, TicketAutomationRunStatus.WAITING_CUSTOMER, ] ), name="unique_active_ticket_automation_run", ) ] indexes = [ models.Index( fields=["ticket", "status", "created_at"], name="tickets_tic_ticket__f9dbaf_idx", ), models.Index( fields=["status", "next_action_at"], name="tickets_tic_status_9ef355_idx", ), ] class TicketAutomationEvent(models.Model): run = models.ForeignKey( TicketAutomationRun, on_delete=models.CASCADE, related_name="events", ) sequence = models.PositiveIntegerField() event_type = models.CharField(max_length=80) payload = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["sequence"] constraints = [ models.UniqueConstraint( fields=["run", "sequence"], name="unique_ticket_automation_event_sequence", ) ] indexes = [ models.Index( fields=["run", "sequence"], name="tickets_tic_run_id_d6464b_idx", ) ] # Kept temporarily for imports owned by stage 2.1 integrations. TicketDraftImage = TicketDraftMaterial class TicketEvent(models.Model): ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events") event_type = models.CharField(max_length=80) from_value = models.JSONField(default=dict, blank=True) to_value = models.JSONField(default=dict, blank=True) operator = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, ) source = models.CharField( max_length=24, choices=TicketActorSource.choices, default=TicketActorSource.HUMAN, ) agent_run_id = models.BigIntegerField(null=True, blank=True) metadata = models.JSONField(default=dict, blank=True) remark = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["created_at"]