Compare commits
4 Commits
9b6d6b3895
...
042dcf8067
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
042dcf8067 | ||
|
|
52becd176a | ||
|
|
993df3a14a | ||
|
|
d455016c27 |
@@ -28,7 +28,7 @@ def _bucket_for_days(days):
|
|||||||
|
|
||||||
|
|
||||||
class FollowupAdapter(DataAdapter):
|
class FollowupAdapter(DataAdapter):
|
||||||
FUSION_MODEL = 'fusion.followup.line'
|
FUSION_MODEL = 'fusion.followup.engine'
|
||||||
ENTERPRISE_MODULE = 'account_followup'
|
ENTERPRISE_MODULE = 'account_followup'
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -179,15 +179,29 @@ class FollowupAdapter(DataAdapter):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# send_followup — Enterprise-only action
|
# send_followup — routes to fusion engine when available
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def send_followup(self, partner_id, options=None):
|
def send_followup(self, partner_id, level_id=None, force=False, options=None):
|
||||||
return self._dispatch('send_followup', partner_id=partner_id, options=options)
|
return self._dispatch(
|
||||||
|
'send_followup',
|
||||||
|
partner_id=partner_id, level_id=level_id,
|
||||||
|
force=force, options=options,
|
||||||
|
)
|
||||||
|
|
||||||
def send_followup_via_fusion(self, partner_id, options=None):
|
def send_followup_via_fusion(self, partner_id, level_id=None,
|
||||||
return self.send_followup_via_community(partner_id=partner_id, options=options)
|
force=False, options=None):
|
||||||
|
if 'fusion.followup.engine' not in self.env.registry:
|
||||||
|
return {'error': 'fusion_accounting_followup not installed'}
|
||||||
|
partner = self.env['res.partner'].browse(int(partner_id))
|
||||||
|
level = None
|
||||||
|
if level_id:
|
||||||
|
level = self.env['fusion.followup.level'].browse(int(level_id))
|
||||||
|
return self.env['fusion.followup.engine'].send_followup_email(
|
||||||
|
partner, level=level, force=bool(force),
|
||||||
|
)
|
||||||
|
|
||||||
def send_followup_via_enterprise(self, partner_id, options=None):
|
def send_followup_via_enterprise(self, partner_id, level_id=None,
|
||||||
|
force=False, options=None):
|
||||||
partner = self.env['res.partner'].browse(partner_id)
|
partner = self.env['res.partner'].browse(partner_id)
|
||||||
if not partner.exists():
|
if not partner.exists():
|
||||||
return {'error': 'Partner not found'}
|
return {'error': 'Partner not found'}
|
||||||
@@ -198,7 +212,8 @@ class FollowupAdapter(DataAdapter):
|
|||||||
'result': str(result) if result else 'done',
|
'result': str(result) if result else 'done',
|
||||||
}
|
}
|
||||||
|
|
||||||
def send_followup_via_community(self, partner_id, options=None):
|
def send_followup_via_community(self, partner_id, level_id=None,
|
||||||
|
force=False, options=None):
|
||||||
return {
|
return {
|
||||||
'error': (
|
'error': (
|
||||||
'Sending follow-ups is only available when account_followup '
|
'Sending follow-ups is only available when account_followup '
|
||||||
@@ -206,5 +221,61 @@ class FollowupAdapter(DataAdapter):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# list_overdue — partner-centric overdue rollup (fusion engine)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def list_overdue(self, status=None, limit=50, company_id=None):
|
||||||
|
return self._dispatch(
|
||||||
|
'list_overdue',
|
||||||
|
status=status, limit=limit, company_id=company_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_overdue_via_fusion(self, status=None, limit=50, company_id=None):
|
||||||
|
if 'fusion.followup.engine' not in self.env.registry:
|
||||||
|
return {'partners': [], 'count': 0, 'total': 0}
|
||||||
|
company_id = company_id or self.env.company.id
|
||||||
|
Line = self.env['account.move.line'].sudo()
|
||||||
|
partner_ids = Line.search([
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('account_id.account_type', '=', 'asset_receivable'),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('amount_residual', '>', 0),
|
||||||
|
('date_maturity', '<', date.today()),
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
]).mapped('partner_id').ids
|
||||||
|
Partner = self.env['res.partner'].sudo()
|
||||||
|
domain = [('id', 'in', partner_ids)]
|
||||||
|
if status:
|
||||||
|
domain.append(('fusion_followup_status', '=', status))
|
||||||
|
partners = Partner.search(domain, limit=int(limit))
|
||||||
|
engine = self.env['fusion.followup.engine']
|
||||||
|
rows = []
|
||||||
|
for p in partners:
|
||||||
|
try:
|
||||||
|
overdue = engine.get_overdue_for_partner(p)
|
||||||
|
rows.append({
|
||||||
|
'partner_id': p.id,
|
||||||
|
'partner_name': p.name,
|
||||||
|
'overdue_amount': overdue['aging']['total_overdue_amount'],
|
||||||
|
'risk_score': overdue['risk']['score'],
|
||||||
|
'risk_band': overdue['risk']['band'],
|
||||||
|
'status': p.fusion_followup_status,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {'count': len(rows), 'total': len(partner_ids), 'partners': rows}
|
||||||
|
|
||||||
|
def list_overdue_via_enterprise(self, status=None, limit=50, company_id=None):
|
||||||
|
return {
|
||||||
|
'partners': [], 'count': 0, 'total': 0,
|
||||||
|
'error': 'Enterprise account_followup must be used from its UI',
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_overdue_via_community(self, status=None, limit=50, company_id=None):
|
||||||
|
return {
|
||||||
|
'partners': [], 'count': 0, 'total': 0,
|
||||||
|
'error': 'No follow-up engine in pure Community',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
register_adapter('followup', FollowupAdapter)
|
register_adapter('followup', FollowupAdapter)
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ from .reporting import TOOLS as REPORTING_TOOLS
|
|||||||
from .audit import TOOLS as AUDIT_TOOLS
|
from .audit import TOOLS as AUDIT_TOOLS
|
||||||
from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS
|
from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS
|
||||||
from .asset_management import TOOLS as ASSET_MANAGEMENT_TOOLS
|
from .asset_management import TOOLS as ASSET_MANAGEMENT_TOOLS
|
||||||
|
from .customer_followup import TOOLS as CUSTOMER_FOLLOWUP_TOOLS
|
||||||
|
|
||||||
TOOL_DISPATCH = {}
|
TOOL_DISPATCH = {}
|
||||||
for tools_dict in [
|
for tools_dict in [
|
||||||
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
|
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
|
||||||
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
|
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
|
||||||
REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS,
|
REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS,
|
||||||
ASSET_MANAGEMENT_TOOLS,
|
ASSET_MANAGEMENT_TOOLS, CUSTOMER_FOLLOWUP_TOOLS,
|
||||||
]:
|
]:
|
||||||
TOOL_DISPATCH.update(tools_dict)
|
TOOL_DISPATCH.update(tools_dict)
|
||||||
|
|||||||
98
fusion_accounting_ai/services/tools/customer_followup.py
Normal file
98
fusion_accounting_ai/services/tools/customer_followup.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Fusion-engine-routed AI tools for customer follow-ups.
|
||||||
|
|
||||||
|
These tools are exposed through TOOL_DISPATCH and let the assistant query
|
||||||
|
the customer follow-up engine via natural language. All tools degrade
|
||||||
|
gracefully when fusion_accounting_followup is not installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_list_overdue(env, params):
|
||||||
|
"""List partners with overdue invoices, sorted by risk."""
|
||||||
|
if 'fusion.followup.engine' not in env.registry:
|
||||||
|
return {'error': 'fusion_accounting_followup not installed'}
|
||||||
|
from ..data_adapters import get_adapter
|
||||||
|
adapter = get_adapter(env, 'followup')
|
||||||
|
return adapter.list_overdue(
|
||||||
|
status=params.get('status'),
|
||||||
|
limit=int(params.get('limit', 50)),
|
||||||
|
company_id=int(params['company_id'])
|
||||||
|
if params.get('company_id') else env.company.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_get_partner_followup_detail(env, params):
|
||||||
|
"""Detailed follow-up state for a single partner: aging, risk, history."""
|
||||||
|
if 'fusion.followup.engine' not in env.registry:
|
||||||
|
return {'error': 'fusion_accounting_followup not installed'}
|
||||||
|
Partner = env['res.partner']
|
||||||
|
partner = Partner.browse(int(params['partner_id']))
|
||||||
|
if not partner.exists():
|
||||||
|
return {'error': 'Partner not found'}
|
||||||
|
engine = env['fusion.followup.engine']
|
||||||
|
overdue = engine.get_overdue_for_partner(partner)
|
||||||
|
history = engine.snapshot_followup_history(partner, limit=10)
|
||||||
|
return {
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'partner_name': partner.name,
|
||||||
|
'overdue': overdue,
|
||||||
|
'history': history,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_generate_followup_text(env, params):
|
||||||
|
"""Generate (or fall back to template) follow-up subject + body."""
|
||||||
|
if 'fusion.followup.engine' not in env.registry:
|
||||||
|
return {'error': 'fusion_accounting_followup not installed'}
|
||||||
|
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
|
||||||
|
generate_followup_text,
|
||||||
|
)
|
||||||
|
return generate_followup_text(
|
||||||
|
env,
|
||||||
|
partner_name=params.get('partner_name', ''),
|
||||||
|
total_overdue=float(params.get('total_overdue', 0)),
|
||||||
|
currency_code=params.get('currency_code', 'USD'),
|
||||||
|
longest_overdue_days=int(params.get('longest_overdue_days', 0)),
|
||||||
|
tone=params.get('tone', 'gentle'),
|
||||||
|
invoice_count=int(params.get('invoice_count', 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_send_followup(env, params):
|
||||||
|
"""Send a follow-up email via the engine (creates a fusion.followup.run)."""
|
||||||
|
if 'fusion.followup.engine' not in env.registry:
|
||||||
|
return {'error': 'fusion_accounting_followup not installed'}
|
||||||
|
from ..data_adapters import get_adapter
|
||||||
|
adapter = get_adapter(env, 'followup')
|
||||||
|
return adapter.send_followup(
|
||||||
|
partner_id=int(params['partner_id']),
|
||||||
|
level_id=int(params['level_id']) if params.get('level_id') else None,
|
||||||
|
force=bool(params.get('force', False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_get_partner_risk_score(env, params):
|
||||||
|
"""Compute and return the payment-risk score + drivers for a partner."""
|
||||||
|
if 'fusion.followup.engine' not in env.registry:
|
||||||
|
return {'error': 'fusion_accounting_followup not installed'}
|
||||||
|
partner = env['res.partner'].browse(int(params['partner_id']))
|
||||||
|
if not partner.exists():
|
||||||
|
return {'error': 'Partner not found'}
|
||||||
|
overdue = env['fusion.followup.engine'].get_overdue_for_partner(partner)
|
||||||
|
return {
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'partner_name': partner.name,
|
||||||
|
'risk': overdue['risk'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = {
|
||||||
|
'fusion_list_overdue': fusion_list_overdue,
|
||||||
|
'fusion_get_partner_followup_detail': fusion_get_partner_followup_detail,
|
||||||
|
'fusion_generate_followup_text': fusion_generate_followup_text,
|
||||||
|
'fusion_send_followup': fusion_send_followup,
|
||||||
|
'fusion_get_partner_risk_score': fusion_get_partner_risk_score,
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting Follow-up',
|
'name': 'Fusion Accounting Follow-up',
|
||||||
'version': '19.0.1.0.14',
|
'version': '19.0.1.0.18',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
|
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -33,6 +33,7 @@ menu hides; the engine + AI tools remain available for the chat.
|
|||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
'data/cron.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import followup_controller
|
||||||
|
|||||||
173
fusion_accounting_followup/controllers/followup_controller.py
Normal file
173
fusion_accounting_followup/controllers/followup_controller.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""HTTP controller: 6 JSON-RPC endpoints for the OWL follow-up dashboard.
|
||||||
|
|
||||||
|
All endpoints route through fusion.followup.engine. V19 type='jsonrpc'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from odoo import _, http
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value):
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return datetime.strptime(value, '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
|
||||||
|
class FusionFollowupController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/fusion/followup/list_overdue', type='jsonrpc', auth='user')
|
||||||
|
def list_overdue(self, limit=50, offset=0, status=None, company_id=None):
|
||||||
|
company_id = int(company_id) if company_id else request.env.company.id
|
||||||
|
Partner = request.env['res.partner'].sudo()
|
||||||
|
Line = request.env['account.move.line'].sudo()
|
||||||
|
overdue_partner_ids = Line.search([
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('account_id.account_type', '=', 'asset_receivable'),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('amount_residual', '>', 0),
|
||||||
|
('date_maturity', '<', date.today()),
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
]).mapped('partner_id').ids
|
||||||
|
|
||||||
|
domain = [('id', 'in', overdue_partner_ids)]
|
||||||
|
if status:
|
||||||
|
domain.append(('fusion_followup_status', '=', status))
|
||||||
|
total = Partner.search_count(domain)
|
||||||
|
partners = Partner.search(domain, limit=int(limit), offset=int(offset))
|
||||||
|
|
||||||
|
engine = request.env['fusion.followup.engine']
|
||||||
|
rows = []
|
||||||
|
for p in partners:
|
||||||
|
try:
|
||||||
|
overdue = engine.get_overdue_for_partner(p)
|
||||||
|
rows.append({
|
||||||
|
'partner_id': p.id,
|
||||||
|
'partner_name': p.name,
|
||||||
|
'email': p.email or '',
|
||||||
|
'status': p.fusion_followup_status,
|
||||||
|
'paused_until': str(p.fusion_followup_paused_until)
|
||||||
|
if p.fusion_followup_paused_until else None,
|
||||||
|
'last_level_id': p.fusion_followup_last_level_id.id
|
||||||
|
if p.fusion_followup_last_level_id else None,
|
||||||
|
'last_level_name': p.fusion_followup_last_level_id.name
|
||||||
|
if p.fusion_followup_last_level_id else None,
|
||||||
|
'last_run_date': str(p.fusion_followup_last_run_date)
|
||||||
|
if p.fusion_followup_last_run_date else None,
|
||||||
|
'overdue_amount': overdue['aging']['total_overdue_amount'],
|
||||||
|
'overdue_line_count': overdue['overdue_line_count'],
|
||||||
|
'risk_score': overdue['risk']['score'],
|
||||||
|
'risk_band': overdue['risk']['band'],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("Skipping partner %s in list: %s", p.id, e)
|
||||||
|
return {'count': len(rows), 'total': total, 'partners': rows}
|
||||||
|
|
||||||
|
@http.route('/fusion/followup/get_partner_detail', type='jsonrpc', auth='user')
|
||||||
|
def get_partner_detail(self, partner_id):
|
||||||
|
partner = request.env['res.partner'].browse(int(partner_id))
|
||||||
|
if not partner.exists():
|
||||||
|
raise ValidationError(_("Partner %s not found") % partner_id)
|
||||||
|
engine = request.env['fusion.followup.engine']
|
||||||
|
overdue = engine.get_overdue_for_partner(partner)
|
||||||
|
history = engine.snapshot_followup_history(partner, limit=20)
|
||||||
|
level = engine.compute_followup_level(partner)
|
||||||
|
return {
|
||||||
|
'partner': {
|
||||||
|
'id': partner.id,
|
||||||
|
'name': partner.name,
|
||||||
|
'email': partner.email or '',
|
||||||
|
'status': partner.fusion_followup_status,
|
||||||
|
'paused_until': str(partner.fusion_followup_paused_until)
|
||||||
|
if partner.fusion_followup_paused_until else None,
|
||||||
|
'last_level_id': partner.fusion_followup_last_level_id.id
|
||||||
|
if partner.fusion_followup_last_level_id else None,
|
||||||
|
'last_level_name': partner.fusion_followup_last_level_id.name
|
||||||
|
if partner.fusion_followup_last_level_id else None,
|
||||||
|
'last_run_date': str(partner.fusion_followup_last_run_date)
|
||||||
|
if partner.fusion_followup_last_run_date else None,
|
||||||
|
'risk_score': partner.fusion_followup_risk_score,
|
||||||
|
'risk_band': partner.fusion_followup_risk_band,
|
||||||
|
},
|
||||||
|
'overdue': overdue,
|
||||||
|
'suggested_level': {
|
||||||
|
'id': level.id, 'name': level.name, 'tone': level.tone,
|
||||||
|
'sequence': level.sequence,
|
||||||
|
} if level else None,
|
||||||
|
'history': history,
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/fusion/followup/generate_text', type='jsonrpc', auth='user')
|
||||||
|
def generate_text(self, partner_id, level_id=None, force_regenerate=False):
|
||||||
|
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
|
||||||
|
generate_followup_text,
|
||||||
|
)
|
||||||
|
from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone
|
||||||
|
|
||||||
|
partner = request.env['res.partner'].browse(int(partner_id))
|
||||||
|
engine = request.env['fusion.followup.engine']
|
||||||
|
if level_id:
|
||||||
|
level = request.env['fusion.followup.level'].browse(int(level_id))
|
||||||
|
else:
|
||||||
|
level = engine.compute_followup_level(partner)
|
||||||
|
if not level:
|
||||||
|
return {'status': 'no_level', 'partner_id': partner.id}
|
||||||
|
|
||||||
|
overdue = engine.get_overdue_for_partner(partner)
|
||||||
|
tone = select_tone(
|
||||||
|
level_sequence=level.sequence,
|
||||||
|
risk_score=overdue['risk']['score'],
|
||||||
|
)
|
||||||
|
|
||||||
|
currency_code = 'USD'
|
||||||
|
if partner.company_id and partner.company_id.currency_id:
|
||||||
|
currency_code = partner.company_id.currency_id.name or 'USD'
|
||||||
|
|
||||||
|
text = generate_followup_text(
|
||||||
|
request.env,
|
||||||
|
partner_name=partner.name,
|
||||||
|
total_overdue=overdue['aging']['total_overdue_amount'],
|
||||||
|
currency_code=currency_code,
|
||||||
|
longest_overdue_days=engine._max_overdue_days_from_aging(overdue['aging']),
|
||||||
|
tone=tone,
|
||||||
|
invoice_count=overdue['overdue_line_count'],
|
||||||
|
risk_drivers=overdue['risk']['drivers'],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'level_id': level.id,
|
||||||
|
'tone': tone,
|
||||||
|
'subject': text.get('subject', ''),
|
||||||
|
'body': text.get('body', ''),
|
||||||
|
'tone_used': text.get('tone_used', tone),
|
||||||
|
'key_points': text.get('key_points', []),
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/fusion/followup/send', type='jsonrpc', auth='user')
|
||||||
|
def send_followup(self, partner_id, level_id=None, force=False):
|
||||||
|
partner = request.env['res.partner'].browse(int(partner_id))
|
||||||
|
engine = request.env['fusion.followup.engine']
|
||||||
|
level = None
|
||||||
|
if level_id:
|
||||||
|
level = request.env['fusion.followup.level'].browse(int(level_id))
|
||||||
|
return engine.send_followup_email(partner, level=level, force=bool(force))
|
||||||
|
|
||||||
|
@http.route('/fusion/followup/pause', type='jsonrpc', auth='user')
|
||||||
|
def pause(self, partner_id, until_date=None):
|
||||||
|
partner = request.env['res.partner'].browse(int(partner_id))
|
||||||
|
engine = request.env['fusion.followup.engine']
|
||||||
|
return engine.pause_followup(partner, until_date=_parse_date(until_date))
|
||||||
|
|
||||||
|
@http.route('/fusion/followup/reset', type='jsonrpc', auth='user')
|
||||||
|
def reset(self, partner_id):
|
||||||
|
partner = request.env['res.partner'].browse(int(partner_id))
|
||||||
|
engine = request.env['fusion.followup.engine']
|
||||||
|
return engine.reset_followup(partner)
|
||||||
24
fusion_accounting_followup/data/cron.xml
Normal file
24
fusion_accounting_followup/data/cron.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="cron_fusion_followup_daily_scan" model="ir.cron">
|
||||||
|
<field name="name">Fusion Follow-up — Daily Scan + Send</field>
|
||||||
|
<field name="model_id" ref="model_fusion_followup_cron"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_daily_scan()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="cron_fusion_followup_risk_refresh" model="ir.cron">
|
||||||
|
<field name="name">Fusion Follow-up — Weekly Risk Refresh</field>
|
||||||
|
<field name="model_id" ref="model_fusion_followup_cron"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_risk_refresh()</field>
|
||||||
|
<field name="interval_number">7</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -4,3 +4,4 @@ from . import fusion_followup_text_cache
|
|||||||
from . import res_partner
|
from . import res_partner
|
||||||
from . import account_move_line
|
from . import account_move_line
|
||||||
from . import fusion_followup_engine
|
from . import fusion_followup_engine
|
||||||
|
from . import fusion_followup_cron
|
||||||
|
|||||||
84
fusion_accounting_followup/models/fusion_followup_cron.py
Normal file
84
fusion_accounting_followup/models/fusion_followup_cron.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Cron handlers for fusion_accounting_followup.
|
||||||
|
|
||||||
|
Two scheduled jobs:
|
||||||
|
- Daily scan: walk every partner with an open overdue receivable line and
|
||||||
|
call the engine to send/escalate where appropriate.
|
||||||
|
- Weekly risk refresh: recompute fusion_followup_risk_score on every
|
||||||
|
partner with overdue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionFollowupCron(models.AbstractModel):
|
||||||
|
_name = "fusion.followup.cron"
|
||||||
|
_description = "Fusion Follow-up Cron Handlers"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_daily_scan(self):
|
||||||
|
"""Scan every partner with overdue and send follow-ups when due."""
|
||||||
|
engine = self.env['fusion.followup.engine']
|
||||||
|
Line = self.env['account.move.line'].sudo()
|
||||||
|
overdue_lines = Line.search([
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('account_id.account_type', '=', 'asset_receivable'),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('amount_residual', '>', 0),
|
||||||
|
('date_maturity', '<', date.today()),
|
||||||
|
])
|
||||||
|
partner_ids = list(set(overdue_lines.mapped('partner_id').ids))
|
||||||
|
sent = 0
|
||||||
|
skipped = 0
|
||||||
|
for pid in partner_ids:
|
||||||
|
partner = self.env['res.partner'].sudo().browse(pid)
|
||||||
|
if not partner.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
result = engine.send_followup_email(partner)
|
||||||
|
if result.get('status') == 'sent':
|
||||||
|
sent += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Cron daily_scan failed for partner %s: %s", pid, e,
|
||||||
|
)
|
||||||
|
skipped += 1
|
||||||
|
_logger.info(
|
||||||
|
"Cron: scanned %d partners, sent %d, skipped %d",
|
||||||
|
len(partner_ids), sent, skipped,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_risk_refresh(self):
|
||||||
|
"""Refresh fusion_followup_risk_score on every partner with overdue."""
|
||||||
|
Partner = self.env['res.partner'].sudo()
|
||||||
|
engine = self.env['fusion.followup.engine']
|
||||||
|
Line = self.env['account.move.line'].sudo()
|
||||||
|
partner_ids = list(set(Line.search([
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('account_id.account_type', '=', 'asset_receivable'),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('amount_residual', '>', 0),
|
||||||
|
]).mapped('partner_id').ids))
|
||||||
|
updated = 0
|
||||||
|
for pid in partner_ids:
|
||||||
|
partner = Partner.browse(pid)
|
||||||
|
try:
|
||||||
|
overdue = engine.get_overdue_for_partner(partner)
|
||||||
|
partner.write({
|
||||||
|
'fusion_followup_risk_score': overdue['risk']['score'],
|
||||||
|
'fusion_followup_risk_band': overdue['risk']['band'],
|
||||||
|
})
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Risk refresh failed for partner %s: %s", pid, e,
|
||||||
|
)
|
||||||
|
_logger.info("Cron: refreshed risk on %d partners", updated)
|
||||||
@@ -10,3 +10,7 @@ from . import test_res_partner_inherit
|
|||||||
from . import test_account_move_line_inherit
|
from . import test_account_move_line_inherit
|
||||||
from . import test_fusion_followup_engine
|
from . import test_fusion_followup_engine
|
||||||
from . import test_engine_integration
|
from . import test_engine_integration
|
||||||
|
from . import test_followup_controller
|
||||||
|
from . import test_followup_adapter
|
||||||
|
from . import test_followup_tools
|
||||||
|
from . import test_followup_cron
|
||||||
|
|||||||
42
fusion_accounting_followup/tests/test_followup_adapter.py
Normal file
42
fusion_accounting_followup/tests/test_followup_adapter.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""FollowupAdapter wiring tests — engine paths."""
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.data_adapters.followup import (
|
||||||
|
FollowupAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestFollowupAdapter(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.adapter = FollowupAdapter(self.env)
|
||||||
|
|
||||||
|
def test_list_overdue_via_fusion_returns_dict(self):
|
||||||
|
result = self.adapter.list_overdue_via_fusion(
|
||||||
|
company_id=self.env.company.id,
|
||||||
|
)
|
||||||
|
self.assertIn('partners', result)
|
||||||
|
self.assertIn('total', result)
|
||||||
|
self.assertIn('count', result)
|
||||||
|
|
||||||
|
def test_list_overdue_via_community_returns_error(self):
|
||||||
|
result = self.adapter.list_overdue_via_community()
|
||||||
|
self.assertIn('error', result)
|
||||||
|
|
||||||
|
def test_send_followup_via_fusion_no_overdue(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'AdapterTest'})
|
||||||
|
result = self.adapter.send_followup_via_fusion(
|
||||||
|
partner_id=partner.id, force=True,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
result.get('status', ''),
|
||||||
|
('no_action', 'no_overdue', 'sent', 'manual_review'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_send_followup_via_community_returns_error(self):
|
||||||
|
result = self.adapter.send_followup_via_community(partner_id=1)
|
||||||
|
self.assertIn('error', result)
|
||||||
80
fusion_accounting_followup/tests/test_followup_controller.py
Normal file
80
fusion_accounting_followup/tests/test_followup_controller.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""HttpCase tests for the 6 follow-up JSON-RPC endpoints."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import HttpCase, new_test_user
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestFollowupController(HttpCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.user = new_test_user(
|
||||||
|
self.env, login='fu_test_user',
|
||||||
|
groups='base.group_user,base.group_partner_manager,'
|
||||||
|
'account.group_account_invoice',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _jsonrpc(self, endpoint, params):
|
||||||
|
self.authenticate('fu_test_user', 'fu_test_user')
|
||||||
|
url = f'/fusion/followup/{endpoint}'
|
||||||
|
body = {'jsonrpc': '2.0', 'method': 'call', 'params': params, 'id': 1}
|
||||||
|
response = self.url_open(
|
||||||
|
url, data=json.dumps(body),
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code, 200,
|
||||||
|
f"{endpoint} returned {response.status_code}: {response.text[:300]}",
|
||||||
|
)
|
||||||
|
result = response.json()
|
||||||
|
if 'error' in result:
|
||||||
|
self.fail(f"{endpoint} errored: {result['error']}")
|
||||||
|
return result.get('result', {})
|
||||||
|
|
||||||
|
def test_list_overdue_returns_dict(self):
|
||||||
|
result = self._jsonrpc('list_overdue', {'company_id': self.env.company.id})
|
||||||
|
self.assertIn('partners', result)
|
||||||
|
self.assertIn('total', result)
|
||||||
|
|
||||||
|
def test_get_partner_detail(self):
|
||||||
|
partner = self.env['res.partner'].create({
|
||||||
|
'name': 'Ctrl Test Partner', 'email': 'ctrl@test.local',
|
||||||
|
})
|
||||||
|
result = self._jsonrpc('get_partner_detail', {'partner_id': partner.id})
|
||||||
|
self.assertEqual(result['partner']['id'], partner.id)
|
||||||
|
self.assertIn('overdue', result)
|
||||||
|
self.assertIn('history', result)
|
||||||
|
|
||||||
|
def test_pause_sets_paused_until(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Pause Test'})
|
||||||
|
future = (date.today() + timedelta(days=20)).isoformat()
|
||||||
|
result = self._jsonrpc('pause', {
|
||||||
|
'partner_id': partner.id, 'until_date': future,
|
||||||
|
})
|
||||||
|
self.assertEqual(result['paused_until'], future)
|
||||||
|
|
||||||
|
def test_reset_clears_status(self):
|
||||||
|
partner = self.env['res.partner'].create({
|
||||||
|
'name': 'Reset Test',
|
||||||
|
'fusion_followup_status': 'paused',
|
||||||
|
})
|
||||||
|
result = self._jsonrpc('reset', {'partner_id': partner.id})
|
||||||
|
self.assertEqual(result['status'], 'reset')
|
||||||
|
|
||||||
|
def test_send_no_overdue_returns_no_action(self):
|
||||||
|
partner = self.env['res.partner'].create({
|
||||||
|
'name': 'No Overdue', 'email': 'no@test.local',
|
||||||
|
})
|
||||||
|
result = self._jsonrpc('send', {
|
||||||
|
'partner_id': partner.id, 'force': True,
|
||||||
|
})
|
||||||
|
self.assertIn(result.get('status'), ('no_action', 'no_overdue'))
|
||||||
|
|
||||||
|
def test_generate_text_no_level_returns_no_level(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'NoLevel Test'})
|
||||||
|
result = self._jsonrpc('generate_text', {'partner_id': partner.id})
|
||||||
|
self.assertIn(result.get('status'), ('no_level', 'ok'))
|
||||||
18
fusion_accounting_followup/tests/test_followup_cron.py
Normal file
18
fusion_accounting_followup/tests/test_followup_cron.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Smoke tests for the fusion follow-up cron handlers."""
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestFollowupCron(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.cron = self.env['fusion.followup.cron']
|
||||||
|
|
||||||
|
def test_cron_daily_scan_runs(self):
|
||||||
|
self.cron._cron_daily_scan()
|
||||||
|
|
||||||
|
def test_cron_risk_refresh_runs(self):
|
||||||
|
self.cron._cron_risk_refresh()
|
||||||
61
fusion_accounting_followup/tests/test_followup_tools.py
Normal file
61
fusion_accounting_followup/tests/test_followup_tools.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""AI tool dispatch tests for fusion follow-up tools."""
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.tools import customer_followup as tools
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestFusionFollowupTools(TransactionCase):
|
||||||
|
|
||||||
|
def test_fusion_list_overdue(self):
|
||||||
|
result = tools.fusion_list_overdue(
|
||||||
|
self.env, {'company_id': self.env.company.id},
|
||||||
|
)
|
||||||
|
self.assertIn('partners', result)
|
||||||
|
|
||||||
|
def test_fusion_get_partner_detail(self):
|
||||||
|
partner = self.env['res.partner'].create({
|
||||||
|
'name': 'Tool Partner', 'email': 't@t.local',
|
||||||
|
})
|
||||||
|
result = tools.fusion_get_partner_followup_detail(
|
||||||
|
self.env, {'partner_id': partner.id},
|
||||||
|
)
|
||||||
|
self.assertEqual(result['partner_id'], partner.id)
|
||||||
|
|
||||||
|
def test_fusion_generate_text_uses_fallback(self):
|
||||||
|
self.env['ir.config_parameter'].sudo().search([
|
||||||
|
('key', 'in', [
|
||||||
|
'fusion_accounting.provider.followup_text',
|
||||||
|
'fusion_accounting.provider.default',
|
||||||
|
]),
|
||||||
|
]).unlink()
|
||||||
|
result = tools.fusion_generate_followup_text(self.env, {
|
||||||
|
'partner_name': 'Acme', 'total_overdue': 1000,
|
||||||
|
'currency_code': 'USD', 'longest_overdue_days': 15,
|
||||||
|
'tone': 'gentle',
|
||||||
|
})
|
||||||
|
self.assertIn('subject', result)
|
||||||
|
self.assertIn('body', result)
|
||||||
|
|
||||||
|
def test_fusion_get_risk_score(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Risk Test'})
|
||||||
|
result = tools.fusion_get_partner_risk_score(
|
||||||
|
self.env, {'partner_id': partner.id},
|
||||||
|
)
|
||||||
|
self.assertIn('risk', result)
|
||||||
|
|
||||||
|
def test_tools_registered_in_dispatch(self):
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH
|
||||||
|
for tool_name in [
|
||||||
|
'fusion_list_overdue',
|
||||||
|
'fusion_get_partner_followup_detail',
|
||||||
|
'fusion_generate_followup_text',
|
||||||
|
'fusion_send_followup',
|
||||||
|
'fusion_get_partner_risk_score',
|
||||||
|
]:
|
||||||
|
self.assertIn(
|
||||||
|
tool_name, TOOL_DISPATCH,
|
||||||
|
f"{tool_name} not registered in TOOL_DISPATCH",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user