Split FSM into separate modules and switch customer to address book

This commit is contained in:
Ruslan Bakiev
2026-02-13 15:27:48 +07:00
parent 98a92286ce
commit dc58e1ffe4
46 changed files with 3125 additions and 152 deletions

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
from . import dsrpt_communication_type
from . import dsrpt_contact
from . import dsrpt_contact_communication
from . import contact_event
from . import contact_source

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
from datetime import timedelta
from odoo import models, fields, api
class ContactEvent(models.Model):
_name = 'contact.event'
_description = 'Contact Event'
_order = 'date_start desc'
_inherit = ['mail.thread', 'mail.activity.mixin']
contact_id = fields.Many2one(
'dsrpt.contact',
string='Contact',
required=True,
ondelete='cascade',
tracking=True
)
date_start = fields.Datetime(
string='Date & Time',
required=True,
default=fields.Datetime.now,
tracking=True
)
duration = fields.Float(
string='Duration (hours)',
default=1.0,
tracking=True
)
user_id = fields.Many2one(
'res.users',
string='Responsible',
default=lambda self: self.env.user,
tracking=True
)
calendar_event_id = fields.Many2one(
'calendar.event',
string='Calendar Event',
readonly=True
)
notes = fields.Text(string='Notes', required=True, tracking=True)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for record in records:
# Get base URL for creating contact link
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069')
contact_url = f"{base_url}/web#id={record.contact_id.id}&model=dsrpt.contact&view_type=form"
# Prepare description with contact link
description = f"📱 Contact in Odoo: {contact_url}\n\n"
if record.notes:
description += record.notes
calendar_event = self.env['calendar.event'].create({
'name': f"[{record.contact_id.name}] {record.notes[:50] if record.notes else 'Event'}",
'start': record.date_start,
'stop': record.date_start + timedelta(hours=record.duration),
'user_id': record.user_id.id,
'partner_ids': [(6, 0, [record.user_id.partner_id.id])] if record.user_id and record.user_id.partner_id else [],
'description': description,
})
record.calendar_event_id = calendar_event.id
# Update next_contact for affected contacts with the event date
for record in records:
record.contact_id._update_next_contact(record.date_start)
return records
def write(self, vals):
result = super().write(vals)
# Update calendar event if needed
for record in self:
if record.calendar_event_id:
update_vals = {}
if 'notes' in vals or 'contact_id' in vals:
update_vals['name'] = f"[{record.contact_id.name}] {record.notes[:50] if record.notes else 'Event'}"
if 'date_start' in vals:
update_vals['start'] = record.date_start
update_vals['stop'] = record.date_start + timedelta(hours=record.duration)
if 'duration' in vals:
update_vals['stop'] = record.date_start + timedelta(hours=record.duration)
if 'user_id' in vals:
update_vals['user_id'] = record.user_id.id
update_vals['partner_ids'] = [(6, 0, [record.user_id.partner_id.id])] if record.user_id and record.user_id.partner_id else []
if 'notes' in vals or 'contact_id' in vals:
# Get base URL for creating contact link
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069')
contact_url = f"{base_url}/web#id={record.contact_id.id}&model=dsrpt.contact&view_type=form"
# Prepare description with contact link
description = f"📱 Contact in Odoo: {contact_url}\n\n"
if record.notes:
description += record.notes
update_vals['description'] = description
if update_vals:
record.calendar_event_id.write(update_vals)
# Update next_contact for affected contacts if relevant fields changed
if any(field in vals for field in ['date_start', 'contact_id']):
for record in self:
record.contact_id._update_next_contact(record.date_start)
return result
@api.ondelete(at_uninstall=False)
def _unlink_calendar_events(self):
for record in self:
if record.calendar_event_id:
record.calendar_event_id.unlink()
def unlink(self):
# Store contacts before deletion for clearing next_contact
contacts = self.mapped('contact_id')
result = super().unlink()
# Clear next_contact for affected contacts after deletion
for contact in contacts:
contact._update_next_contact(None)
return result

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class ContactSource(models.Model):
_name = 'contact.source'
_description = 'Contact Source'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'name'
_parent_store = True
name = fields.Char(string='Name', required=True, translate=True, tracking=True)
description = fields.Text(string='Description', tracking=True)
active = fields.Boolean(string='Active', default=True, tracking=True)
parent_id = fields.Many2one('contact.source', string='Parent Source', ondelete='cascade', index=True, tracking=True)
parent_path = fields.Char(index=True, unaccent=False)
# Стандартные статусы согласно требованиям
state = fields.Selection([
('draft', 'Draft'),
('active', 'Active')
], string='State', default='draft', required=True, tracking=True)
color = fields.Integer(string='Color', default=0)
def action_activate(self):
"""Активировать источник"""
self.write({'state': 'active'})
def action_draft(self):
"""Вернуть в черновик"""
self.write({'state': 'draft'})

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class CommunicationType(models.Model):
_name = 'dsrpt.communication.type'
_description = 'Communication Type'
_order = 'name'
name = fields.Char(string='Name', required=True, translate=True)
code = fields.Char(string='Code', required=True)
active = fields.Boolean(string='Active', default=True)
# Стандартные статусы согласно требованиям
state = fields.Selection([
('draft', 'Draft'),
('active', 'Active')
], string='State', default='draft', required=True)
def action_activate(self):
"""Активировать тип коммуникации"""
self.write({'state': 'active'})
def action_draft(self):
"""Вернуть в черновик"""
self.write({'state': 'draft'})

View File

@@ -0,0 +1,332 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class Contact(models.Model):
_name = 'dsrpt.contact'
_description = 'Contact'
_inherit = ['mail.thread', 'mail.activity.mixin']
_rec_name = 'name'
_order = 'create_date desc, name'
name = fields.Char(string='Name', required=True, tracking=True)
display_name = fields.Char(string='Display Name') # Will be computed in realty_sales module
active = fields.Boolean(string='Active', default=True, tracking=True)
status = fields.Selection([
('awaiting_qualification', 'Awaiting Qualification'),
('not_customer', 'Not a Customer'),
('supplier', 'Supplier'),
('has_request', 'Has Request'),
('has_contract', 'Has Contract'),
('potential_investor', 'Potential Investor'),
('cancelled', 'Cancelled')
], string='Status', default='awaiting_qualification', tracking=True, help='Contact processing status for AI analysis')
user_id = fields.Many2one('res.users', string='Responsible', tracking=True)
source_id = fields.Many2one('contact.source', string='Source', tracking=True)
qualification_date = fields.Datetime(string='Qualification Date', tracking=True, help='When contact was qualified')
communication_ids = fields.One2many(
'dsrpt.contact.communication',
'contact_id',
string='Communications'
)
note = fields.Text(string='Note')
# Relations
event_ids = fields.One2many(
'contact.event',
'contact_id',
string='Events'
)
# call_ids moved to dsrpt_calls module to avoid circular dependencies
# Computed fields
event_count = fields.Integer(
string='Events',
compute='_compute_event_count'
)
phone_numbers = fields.Char(
string='Phone Numbers',
compute='_compute_phone_numbers',
store=True
)
next_contact = fields.Datetime(
string='Next Contact',
tracking=True,
help='Date of the nearest planned event'
)
# call_count moved to dsrpt_calls module
@api.depends('event_ids')
def _compute_event_count(self):
"""Counts the number of events"""
for record in self:
record.event_count = len(record.event_ids)
@api.depends('communication_ids.value', 'communication_ids.communication_type_id')
def _compute_phone_numbers(self):
"""Extracts all phone numbers for search functionality"""
for record in self:
phones = record.communication_ids.filtered(
lambda c: c.communication_type_id.code == 'phone'
).mapped('value')
record.phone_numbers = ', '.join(phones) if phones else ''
def _update_next_contact(self, date_value=None):
"""Update next_contact field with provided date"""
for record in self:
if date_value is not None:
record.next_contact = date_value
# _compute_call_count moved to dsrpt_calls module
def action_view_events(self):
"""Opens contact events"""
self.ensure_one()
return {
'name': 'Events',
'type': 'ir.actions.act_window',
'res_model': 'contact.event',
'view_mode': 'list,form',
'domain': [('contact_id', '=', self.id)],
'context': {'default_contact_id': self.id}
}
def action_qualify(self):
"""Action to trigger qualification for selected contacts"""
qualified_count = 0
no_calls_count = 0
already_qualified_count = 0
for contact in self:
# Skip if already qualified (status not 'awaiting_qualification')
if contact.status != 'awaiting_qualification':
already_qualified_count += 1
continue
# Find last call with transcription for this contact
last_call = self.env['dsrpt_calls.call'].search([
('contact_id', '=', contact.id),
('transcription', '!=', False)
], order='date_start desc', limit=1)
if last_call:
# Send on_need_qualification event directly to contact
contact._event('on_need_qualification').notify(contact)
qualified_count += 1
else:
no_calls_count += 1
# Build notification message
message_parts = []
if qualified_count:
message_parts.append(f"{qualified_count} contacts queued for qualification")
if no_calls_count:
message_parts.append(f"{no_calls_count} contacts have no calls with transcription")
if already_qualified_count:
message_parts.append(f"{already_qualified_count} contacts already qualified")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Qualification Process',
'message': '. '.join(message_parts) if message_parts else 'No contacts to qualify',
'type': 'success' if qualified_count else 'info',
'sticky': False,
}
}
def action_view_calls(self):
"""Opens contact calls"""
self.ensure_one()
return {
'name': 'Calls',
'type': 'ir.actions.act_window',
'res_model': 'dsrpt_calls.call',
'view_mode': 'list,form',
'domain': [('contact_id', '=', self.id)],
'context': {'default_contact_id': self.id}
}
def name_get(self):
result = []
for record in self:
# Найти предпочитаемый способ связи
preferred = record.communication_ids.filtered('is_preferred')
if preferred:
name = f"{record.name} ({preferred[0].value})"
else:
name = record.name
result.append((record.id, name))
return result
def action_view_telegram_card(self):
"""Open telegram card editing form"""
self.ensure_one()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069')
card_url = f"{base_url}/telegram/contact-card/{self.id}"
return {
'type': 'ir.actions.act_url',
'url': card_url,
'target': 'new',
}
def _process_name_suggestion_from_call(self, call_record):
"""Queue job method to process name suggestion from call transcription"""
import logging
_logger = logging.getLogger(__name__)
if not call_record.transcription:
_logger.warning(f"No transcription available for call {call_record.id}")
return
# Get AI collection and component
collection = self.env['dsrpt.ai.collection'].search([], limit=1)
if not collection:
_logger.error("No AI collection found")
return
work = collection.work_on(model_name='field.change.suggestion')
ai_component = work.component(usage='field.change.suggestion')
# Get suggested name from AI
suggested_name = ai_component.suggest_name_from_transcription(call_record.transcription)
# Only create suggestion if AI returned a valid name
if suggested_name and suggested_name not in ['Не определено', 'Undefined', '', False, 'Unknown']:
# Find telegram user for notification
telegram_user = self._find_telegram_user_for_suggestions()
# Create field suggestion with AI result
suggestion_vals = {
'res_model': 'dsrpt.contact',
'res_id': self.id,
'field_name': 'name',
'current_value': self.name,
'suggested_value': suggested_name,
'telegram_user_id': telegram_user.id if telegram_user else None
}
suggestion = self.env['field.change.suggestion'].create(suggestion_vals)
_logger.info(f"Created name suggestion {suggestion.id} with value '{suggested_name}' for contact {self.id}")
else:
_logger.info(f"AI did not return valid name suggestion for contact {self.id}, skipping")
def _find_telegram_user_for_suggestions(self):
"""Find telegram user for notifications"""
telegram_user = None
# Priority: contact owner > first admin user
if hasattr(self, 'user_id') and self.user_id:
telegram_user = self.env['telegram.user'].search([
('odoo_user_id', '=', self.user_id.id),
('active', '=', True)
], limit=1)
if not telegram_user:
# Find first active telegram admin user
telegram_user = self.env['telegram.user'].search([
('active', '=', True)
], limit=1)
return telegram_user
def _process_note_suggestion_from_context(self, context_record):
"""Queue job method to process note suggestion from contact history"""
import logging
_logger = logging.getLogger(__name__)
# Get AI collection and component
collection = self.env['dsrpt.ai.collection'].search([], limit=1)
if not collection:
_logger.error("No AI collection found")
return
work = collection.work_on(model_name='field.change.suggestion')
ai_component = work.component(usage='field.change.suggestion')
# Gather contact history for AI
history = self._gather_contact_history_for_ai()
# Get suggested summary from AI
suggested_summary = ai_component.suggest_summary_from_history(history)
# Only create suggestion if AI returned a valid summary
if suggested_summary and suggested_summary not in ['Не определено', 'Undefined', '', False]:
# Find telegram user for notification
telegram_user = self._find_telegram_user_for_suggestions()
# Create field suggestion with AI result
suggestion_vals = {
'res_model': 'dsrpt.contact',
'res_id': self.id,
'field_name': 'note',
'current_value': self.note or '',
'suggested_value': suggested_summary,
'telegram_user_id': telegram_user.id if telegram_user else None
}
suggestion = self.env['field.change.suggestion'].create(suggestion_vals)
_logger.info(f"Created note suggestion {suggestion.id} with summary for contact {self.id}")
else:
_logger.info(f"AI did not return valid note suggestion for contact {self.id}, skipping")
def _gather_contact_history_for_ai(self):
"""Gather contact history for summary generation"""
history_parts = []
# Add contact basic info
history_parts.append(f"Contact: {self.name}")
# Add communications
for comm in self.communication_ids:
history_parts.append(f"{comm.communication_type_id.name}: {comm.value}")
# Add recent calls if module is installed
if 'dsrpt_calls.call' in self.env:
calls = self.env['dsrpt_calls.call'].search([
('contact_id', '=', self.id)
], limit=5, order='date_start desc')
for call in calls:
history_parts.append(f"Call {call.date_start}: {call.duration} sec")
if call.transcription:
history_parts.append(f"Summary: {call.transcription[:200]}...")
return "\n".join(history_parts)
def recompute_next_contact_all(self):
"""Clear next_contact for all contacts (simplified logic)"""
all_contacts = self.env['dsrpt.contact'].search([])
all_contacts._update_next_contact(None)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Reset Completed',
'message': f'"Next Contact" cleared for {len(all_contacts)} contacts',
'type': 'success',
'sticky': False,
}
}
def write(self, vals):
"""Override write to automatically set qualification_date when status changes"""
# Check if status is being changed
if 'status' in vals:
for record in self:
old_status = record.status
new_status = vals['status']
# If changing FROM awaiting_qualification TO any other status
if (old_status == 'awaiting_qualification' and
new_status != 'awaiting_qualification' and
not record.qualification_date):
# Set qualification date automatically
vals['qualification_date'] = fields.Datetime.now()
return super().write(vals)

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class ContactCommunication(models.Model):
_name = 'dsrpt.contact.communication'
_description = 'Contact Communication'
_order = 'is_preferred desc, id'
contact_id = fields.Many2one(
'dsrpt.contact',
string='Contact',
required=True,
ondelete='cascade'
)
communication_type_id = fields.Many2one(
'dsrpt.communication.type',
string='Type',
required=True
)
value = fields.Char(string='Value', required=True)
is_preferred = fields.Boolean(string='Preferred', default=False)
@api.constrains('is_preferred')
def _check_single_preferred(self):
"""Ensure only one preferred communication per contact"""
for record in self:
if record.is_preferred:
# Проверяем есть ли другие preferred для этого контакта
domain = [
('contact_id', '=', record.contact_id.id),
('is_preferred', '=', True),
('id', '!=', record.id)
]
if self.search_count(domain) > 0:
raise ValidationError(
'Only one communication method can be marked as preferred per contact.'
)
@api.model
def create(self, vals):
# Если это первый способ связи для контакта, делаем его preferred
if 'contact_id' in vals and not vals.get('is_preferred'):
existing = self.search_count([('contact_id', '=', vals['contact_id'])])
if existing == 0:
vals['is_preferred'] = True
return super().create(vals)
def name_get(self):
result = []
for record in self:
name = f"{record.communication_type_id.name}: {record.value}"
if record.is_preferred:
name = f"{name}"
result.append((record.id, name))
return result