# -*- coding: utf-8 -*- import json from odoo import api, fields, models, _ from odoo.exceptions import UserError class WheelchairConfigFlow(models.Model): _name = 'fusion.wc.config.flow' _description = 'Wheelchair Configuration Flow' _order = 'sequence, id' name = fields.Char(string='Name', required=True, help='e.g. "Standard Manual Wheelchair Config"') active = fields.Boolean(default=True) sequence = fields.Integer(default=10) equipment_type = fields.Selection( selection='_get_equipment_type_selection', string='Equipment Type', required=True) @api.model def _get_equipment_type_selection(self): types = self.env['fusion.equipment.type'].sudo().search([], order='sequence') if types: return [(t.code, t.name) for t in types] return [ ('manual_wheelchair', 'Manual Wheelchair'), ('power_wheelchair', 'Power Wheelchair'), ('walker', 'Walker / Ambulation Aid'), ] description = fields.Text(string='Description') # Canvas state — JSON blob for viewport (zoom, pan) preserved across sessions canvas_data = fields.Text(string='Canvas Data', default='{}', help='JSON: viewport state for the visual designer') state = fields.Selection([ ('draft', 'Draft'), ('active', 'Active'), ('archived', 'Archived'), ], string='Status', default='draft', tracking=True) # Relationships node_ids = fields.One2many('fusion.wc.config.flow.node', 'flow_id', string='Nodes') connection_ids = fields.One2many('fusion.wc.config.flow.connection', 'flow_id', string='Connections') step_ids = fields.One2many('fusion.wc.config.flow.step', 'flow_id', string='Form Steps') # Computed counts node_count = fields.Integer(string='Nodes', compute='_compute_counts') connection_count = fields.Integer(string='Connections', compute='_compute_counts') step_count = fields.Integer(string='Steps', compute='_compute_counts') @api.depends('node_ids', 'connection_ids', 'step_ids') def _compute_counts(self): for rec in self: rec.node_count = len(rec.node_ids) rec.connection_count = len(rec.connection_ids) rec.step_count = len(rec.step_ids) # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def action_open_designer(self): """Open the visual flow designer (OWL client action).""" self.ensure_one() return { 'type': 'ir.actions.client', 'tag': 'fusion_flow_designer', 'name': _('Flow Designer: %s') % self.name, 'context': {'active_id': self.id}, } def action_activate(self): """Set flow to active. Deactivate other flows for same equipment type. Auto-creates default steps if flow has no steps defined. """ self.ensure_one() # Auto-create default steps if none exist if not self.step_ids: self.action_create_default_steps() # Deactivate other active flows for the same equipment type siblings = self.search([ ('equipment_type', '=', self.equipment_type), ('state', '=', 'active'), ('id', '!=', self.id), ]) siblings.write({'state': 'draft'}) self.write({'state': 'active'}) def action_archive(self): self.ensure_one() self.write({'state': 'archived'}) def action_reset_draft(self): self.ensure_one() self.write({'state': 'draft'}) def action_create_default_steps(self): """Generate default form steps based on equipment type. For wheelchair types, creates the standard 6 steps. For other types, creates a generic 4-step flow. """ self.ensure_one() Step = self.env['fusion.wc.config.flow.step'] # Remove existing steps self.step_ids.unlink() wheelchair_types = ('manual_wheelchair', 'power_wheelchair') if self.equipment_type in wheelchair_types: # ── Client step config: ADP program fields ── is_manual = self.equipment_type == 'manual_wheelchair' client_config = json.dumps({ "show_health_card": True, "show_dob": True, "show_adp_fields": True, # client_type + reason_for_application "show_wheelchair_category": is_manual, "show_powerchair_category": not is_manual, }) # ── Measurement fields ── mw_measurements_json = json.dumps([ {"name": "seat_width", "label": "Seat Width", "type": "float", "unit_field": "seat_width_unit", "units": ["inches", "cm"], "required": True}, {"name": "seat_depth", "label": "Seat Depth", "type": "float", "unit_field": "seat_depth_unit", "units": ["inches", "cm"], "required": True}, {"name": "finished_seat_to_floor_height", "label": "Finished Seat to Floor Height", "type": "float", "unit_field": "seat_to_floor_unit", "units": ["inches", "cm"]}, {"name": "back_cane_height", "label": "Back Cane Height", "type": "float", "unit_field": "cane_height_unit", "units": ["inches", "cm"]}, {"name": "finished_back_height", "label": "Finished Back Height", "type": "float", "unit_field": "back_height_unit", "units": ["inches", "cm"]}, {"name": "finished_leg_rest_length", "label": "Finished Leg Rest Length", "type": "float", "unit_field": "leg_rest_unit", "units": ["inches", "cm"]}, {"name": "client_weight", "label": "Client Weight", "type": "float", "unit_field": "client_weight_unit", "units": ["lbs", "kg"], "required": True}, ]) prefix = 'mw' if is_manual else 'pw' steps = [ {'sequence': 10, 'name': 'Client', 'step_type': 'client_info', 'icon': 'fa-user', 'fields_json': client_config}, {'sequence': 20, 'name': 'Measurements', 'step_type': 'measurements', 'icon': 'fa-ruler', 'fields_json': mw_measurements_json}, {'sequence': 30, 'name': 'Frame', 'step_type': 'product_select', 'icon': 'fa-wheelchair', 'section_code': f'{prefix}_frame'}, {'sequence': 40, 'name': 'Seating', 'step_type': 'options', 'icon': 'fa-chair', 'section_code': 'seating'}, {'sequence': 50, 'name': 'Options', 'step_type': 'options', 'icon': 'fa-list', 'section_code': f'{prefix}_adp_options,{prefix}_accessories'}, {'sequence': 60, 'name': 'Review', 'step_type': 'review', 'icon': 'fa-check'}, ] elif self.equipment_type == 'walker': # ── Client step config: ADP program fields (no wheelchair categories) ── client_config = json.dumps({ "show_health_card": True, "show_dob": True, "show_adp_fields": True, }) walker_measurements_json = json.dumps([ {"name": "walker_seat_height", "label": "Seat Height", "type": "float", "unit_field": "walker_seat_height_unit", "units": ["inches", "cm", "na"]}, {"name": "push_handle_height", "label": "Push Handle Height", "type": "float", "unit_field": "push_handle_height_unit", "units": ["inches", "cm"]}, {"name": "width_between_push_handles", "label": "Width Between Push Handles", "type": "float", "unit_field": "push_handle_width_unit", "units": ["inches", "cm"]}, {"name": "client_weight", "label": "Client Weight", "type": "float", "unit_field": "client_weight_unit", "units": ["lbs", "kg"], "required": True}, ]) steps = [ {'sequence': 10, 'name': 'Client', 'step_type': 'client_info', 'icon': 'fa-user', 'fields_json': client_config}, {'sequence': 20, 'name': 'Measurements', 'step_type': 'measurements', 'icon': 'fa-ruler', 'fields_json': walker_measurements_json}, {'sequence': 30, 'name': 'Equipment', 'step_type': 'product_select', 'icon': 'fa-male', 'section_code': 'walker_frame'}, {'sequence': 40, 'name': 'Options', 'step_type': 'options', 'icon': 'fa-list', 'section_code': 'walker_adp_options,walker_accessories'}, {'sequence': 50, 'name': 'Review', 'step_type': 'review', 'icon': 'fa-check'}, ] else: # ── Generic flow for new equipment types (stair lift, porch lift, etc.) ── # No ADP fields, no health card, no DOB — pure quotation tool steps = [ {'sequence': 10, 'name': 'Client', 'step_type': 'client_info', 'icon': 'fa-user'}, # no fields_json → no optional groups {'sequence': 20, 'name': 'Equipment', 'step_type': 'product_select', 'icon': 'fa-cog'}, {'sequence': 30, 'name': 'Options', 'step_type': 'options', 'icon': 'fa-list'}, {'sequence': 40, 'name': 'Review', 'step_type': 'review', 'icon': 'fa-check'}, ] for step_vals in steps: step_vals['flow_id'] = self.id Step.create(step_vals) def action_new_assessment_form(self): """Open a new portal assessment form pre-selected to this flow's equipment type.""" self.ensure_one() base = self.env['ir.config_parameter'].sudo().get_param('web.base.url') return { 'type': 'ir.actions.act_url', 'url': f'{base}/my/quotation/builder/new?equipment_type={self.equipment_type}', 'target': 'new', } # ------------------------------------------------------------------ # Load / Save graph for designer # ------------------------------------------------------------------ def load_flow_graph(self): """Return the complete flow graph for the visual designer.""" self.ensure_one() nodes = [] for n in self.node_ids: node_data = { 'id': n.id, 'name': n.name, 'node_type': n.node_type, 'pos_x': n.pos_x, 'pos_y': n.pos_y, 'color': n.color, 'icon': n.icon, 'section_id': n.section_id.id if n.section_id else False, 'section_name': n.section_id.name if n.section_id else '', 'decision_field': n.decision_field or '', 'decision_operator': n.decision_operator or '', 'decision_value': n.decision_value or '', 'measurement_field': n.measurement_field or '', 'comparison': n.comparison or '', 'threshold_value': n.threshold_value, 'action_type': n.action_type or '', 'target_option_ids': n.target_option_ids.ids, 'target_step': n.target_step, 'config_json': n.config_json or '{}', 'node_options': [{ 'id': opt.id, 'name': opt.name, 'sequence': opt.sequence, 'section_option_id': opt.section_option_id.id if opt.section_option_id else False, 'enables_option_ids': opt.enables_option_ids.ids, 'disables_option_ids': opt.disables_option_ids.ids, 'requires_option_ids': opt.requires_option_ids.ids, 'port_key': opt.port_key, } for opt in n.node_option_ids], } nodes.append(node_data) connections = [] for c in self.connection_ids: connections.append({ 'id': c.id, 'source_node_id': c.source_node_id.id, 'target_node_id': c.target_node_id.id, 'source_port': c.source_port or 'out', 'label': c.label or '', 'condition_json': c.condition_json or '{}', 'sequence': c.sequence, }) return { 'id': self.id, 'name': self.name, 'equipment_type': self.equipment_type, 'canvas': json.loads(self.canvas_data or '{}'), 'nodes': nodes, 'connections': connections, } def save_flow_graph(self, graph_data): """Sync the full graph from the visual designer to ORM records.""" self.ensure_one() Node = self.env['fusion.wc.config.flow.node'] Connection = self.env['fusion.wc.config.flow.connection'] # Save viewport state canvas = graph_data.get('canvas', {}) self.write({'canvas_data': json.dumps(canvas)}) # --- Sync Nodes --- incoming_nodes = graph_data.get('nodes', []) incoming_node_ids = set() node_id_map = {} # temp_id -> real_id for ndata in incoming_nodes: vals = { 'flow_id': self.id, 'name': ndata.get('name', 'Untitled'), 'node_type': ndata.get('node_type', 'action'), 'pos_x': ndata.get('pos_x', 0), 'pos_y': ndata.get('pos_y', 0), 'color': ndata.get('color', '#3b82f6'), 'icon': ndata.get('icon', 'fa-circle'), 'section_id': ndata.get('section_id') or False, 'decision_field': ndata.get('decision_field') or False, 'decision_operator': ndata.get('decision_operator') or False, 'decision_value': ndata.get('decision_value') or '', 'measurement_field': ndata.get('measurement_field') or False, 'comparison': ndata.get('comparison') or False, 'threshold_value': ndata.get('threshold_value', 0), 'action_type': ndata.get('action_type') or False, 'target_step': ndata.get('target_step', 0), 'config_json': ndata.get('config_json', '{}'), } target_ids = ndata.get('target_option_ids', []) node_id = ndata.get('id') if isinstance(node_id, int) and node_id > 0: # Update existing node = Node.browse(node_id) if node.exists(): node.write(vals) if target_ids is not None: node.write({'target_option_ids': [(6, 0, target_ids)]}) node_id_map[node_id] = node_id incoming_node_ids.add(node_id) continue # Create new new_node = Node.create(vals) if target_ids: new_node.write({'target_option_ids': [(6, 0, target_ids)]}) node_id_map[ndata.get('id', 'new')] = new_node.id incoming_node_ids.add(new_node.id) # Delete nodes no longer in the graph existing_nodes = Node.search([('flow_id', '=', self.id)]) to_delete = existing_nodes.filtered(lambda n: n.id not in incoming_node_ids) to_delete.unlink() # --- Sync Connections --- incoming_conns = graph_data.get('connections', []) incoming_conn_ids = set() for cdata in incoming_conns: src_id = cdata.get('source_node_id') tgt_id = cdata.get('target_node_id') # Resolve temp IDs src_id = node_id_map.get(src_id, src_id) tgt_id = node_id_map.get(tgt_id, tgt_id) vals = { 'flow_id': self.id, 'source_node_id': src_id, 'target_node_id': tgt_id, 'source_port': cdata.get('source_port', 'out'), 'label': cdata.get('label', ''), 'condition_json': cdata.get('condition_json', '{}'), 'sequence': cdata.get('sequence', 10), } conn_id = cdata.get('id') if isinstance(conn_id, int) and conn_id > 0: conn = Connection.browse(conn_id) if conn.exists(): conn.write(vals) incoming_conn_ids.add(conn_id) continue new_conn = Connection.create(vals) incoming_conn_ids.add(new_conn.id) # Delete connections no longer in the graph existing_conns = Connection.search([('flow_id', '=', self.id)]) to_delete = existing_conns.filtered(lambda c: c.id not in incoming_conn_ids) to_delete.unlink() return True # ------------------------------------------------------------------ # Flow Evaluation Engine # ------------------------------------------------------------------ def evaluate(self, assessment_data): """Walk the flow graph and return option directives. Args: assessment_data: dict with keys like equipment_type, seat_width, selected_option_ids, build_type, etc. Returns: dict with enabled/disabled/required option IDs, skip steps, messages. """ self.ensure_one() result = { 'enabled_option_ids': set(), 'disabled_option_ids': set(), 'required_option_ids': set(), 'skip_steps': set(), 'active_nodes': [], 'messages': [], } # Find start node start_nodes = self.node_ids.filtered(lambda n: n.node_type == 'start') if not start_nodes: return self._finalize_result(result) # BFS walk with cycle protection visited = set() queue = list(start_nodes) max_iterations = 200 iteration = 0 while queue and iteration < max_iterations: iteration += 1 node = queue.pop(0) if node.id in visited: continue visited.add(node.id) result['active_nodes'].append(node.id) next_nodes = self._evaluate_node(node, assessment_data, result) queue.extend(next_nodes) return self._finalize_result(result) def _finalize_result(self, result): """Convert sets to sorted lists for JSON serialization.""" for key in ('enabled_option_ids', 'disabled_option_ids', 'required_option_ids', 'skip_steps'): result[key] = sorted(result[key]) return result def _evaluate_node(self, node, data, result): """Evaluate a single node. Returns list of next nodes to visit.""" if node.node_type == 'start': return self._get_next_nodes(node, 'out') elif node.node_type == 'end': return [] elif node.node_type == 'decision': passed = self._evaluate_decision(node, data) return self._get_next_nodes(node, 'true' if passed else 'false') elif node.node_type == 'measurement_check': passed = self._evaluate_measurement(node, data) return self._get_next_nodes(node, 'pass' if passed else 'fail') elif node.node_type == 'option_group': selected = set(data.get('selected_option_ids', [])) for opt in node.node_option_ids: if opt.section_option_id and opt.section_option_id.id in selected: result['enabled_option_ids'].update(opt.enables_option_ids.ids) result['disabled_option_ids'].update(opt.disables_option_ids.ids) result['required_option_ids'].update(opt.requires_option_ids.ids) return self._get_next_nodes(node, 'out') elif node.node_type == 'action': if node.action_type == 'enable': result['enabled_option_ids'].update(node.target_option_ids.ids) elif node.action_type == 'disable': result['disabled_option_ids'].update(node.target_option_ids.ids) elif node.action_type == 'require': result['required_option_ids'].update(node.target_option_ids.ids) elif node.action_type == 'skip_step' and node.target_step: result['skip_steps'].add(node.target_step) elif node.action_type == 'set_value': msg = json.loads(node.config_json or '{}').get('message', '') if msg: result['messages'].append(msg) return self._get_next_nodes(node, 'out') elif node.node_type == 'product_select': return self._get_next_nodes(node, 'out') return [] def _evaluate_decision(self, node, data): """Evaluate a decision node condition against assessment data.""" field = node.decision_field op = node.decision_operator expected = node.decision_value or '' actual = data.get(field, '') if isinstance(actual, (int, float)) and expected: try: expected_num = float(expected) actual_num = float(actual) if op == 'eq': return actual_num == expected_num elif op == 'neq': return actual_num != expected_num elif op == 'gt': return actual_num > expected_num elif op == 'gte': return actual_num >= expected_num elif op == 'lt': return actual_num < expected_num elif op == 'lte': return actual_num <= expected_num except (ValueError, TypeError): pass # String comparison actual_str = str(actual) if op == 'eq': return actual_str == expected elif op == 'neq': return actual_str != expected elif op == 'in': return actual_str in [v.strip() for v in expected.split(',')] return False def _evaluate_measurement(self, node, data): """Evaluate a measurement check node.""" field = node.measurement_field if not field: return False actual = data.get(field, 0) try: actual = float(actual) except (ValueError, TypeError): return False threshold = node.threshold_value comp = node.comparison if comp == 'gt': return actual > threshold elif comp == 'gte': return actual >= threshold elif comp == 'lt': return actual < threshold elif comp == 'eq': return abs(actual - threshold) < 0.001 elif comp == 'neq': return abs(actual - threshold) >= 0.001 return False def _get_next_nodes(self, node, port): """Get target nodes for outgoing connections from a specific port.""" connections = node.outgoing_connection_ids.filtered( lambda c: c.source_port == port ) return connections.mapped('target_node_id')