# -*- coding: utf-8 -*- from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError from dateutil.relativedelta import relativedelta from datetime import datetime, time from odoo import Command # Import Command para operaciones de One2many import logging from collections import defaultdict _logger = logging.getLogger(__name__) class SaleOrderTemplate(models.Model): _inherit = 'sale.order.template' use_contract_partner = fields.Boolean( string='Usar la plantilla para creación de órdenes recurrentes', help="" ) monthly_invoice_project = fields.Boolean( string='Generar ventas y proyectos mensuales', default=True, help="" ) contract_partner_id = fields.Many2one( 'res.partner', string='Cliente', help="" ) payment_term_id = fields.Many2one( 'account.payment.term', string='Términos de Pago', company_dependent=True, help="Términos de pago que se aplicarán a los pedidos generados desde esta plantilla." ) date_start = fields.Date( string='Fecha de Inicio del Contrato', help="Fecha de inicio para el rango del contrato de esta plantilla." ) date_end = fields.Date( string='Fecha de Fin del Contrato', help="Fecha de finalización para el rango del contrato de esta plantilla." ) @api.constrains('use_contract_partner', 'contract_partner_id', 'payment_term_id', 'date_start', 'date_end') def _check_contract_fields_required(self): for rec in self: if rec.use_contract_partner: missing = [] if not rec.contract_partner_id: missing.append(_('Cliente')) if not rec.payment_term_id: missing.append(_('Términos de Pago')) if not rec.date_start: missing.append(_('Fecha de Inicio')) if not rec.date_end: missing.append(_('Fecha de Fin')) if missing: raise ValidationError( _('Los siguientes campos son obligatorios cuando se usa la opción de contrato: %s') % ', '.join(missing) ) @api.constrains('sale_order_template_line_ids') def _check_projects_not_used_in_other_templates(self): for rec in self: # Obtener todos los proyectos seleccionados en las líneas de la plantilla actual project_ids = [line.project_id.id for line in rec.sale_order_template_line_ids if line.project_id] if not project_ids: continue # Buscar si alguno de estos proyectos está en otra plantilla conflict_lines = rec.env['sale.order.template.line'].search([ ('project_id', 'in', project_ids), ('sale_order_template_id', '!=', rec.id) ]) if conflict_lines: conflicts = {} for line in conflict_lines: conflicts.setdefault(line.project_id.name, set()).add(line.sale_order_template_id.name) msg = _('Los siguientes proyectos ya están usados en otras plantillas:') for project_name, template_names in conflicts.items(): msg += f"\n- {project_name}: {', '.join(template_names)}" raise ValidationError(msg) @api.constrains('date_start', 'date_end') def _check_date_start_end(self): for rec in self: if rec.date_start and rec.date_end and rec.date_end <= rec.date_start: raise ValidationError(_('La fecha de fin debe ser mayor a la fecha de inicio.')) @api.constrains('sale_order_template_line_ids') def _check_employee_required_for_duplicate_partner_project(self): for rec in self: # Agrupar por (contract_partner_id, project_id) combos = {} for line in rec.sale_order_template_line_ids: key = (line.contract_partner_id.id, line.project_id.id) if not all(key): continue combos.setdefault(key, []).append(line) # Revisar si hay más de una línea para el mismo combo for key, lines in combos.items(): if len(lines) > 1: employee_ids = set() for l in lines: if not l.employee_id: raise ValidationError(_( 'Si hay más de una línea con el mismo Cliente Final y Proyecto, el campo Empleado es obligatorio en todas esas líneas. (Cliente: %s, Proyecto: %s)' ) % (l.contract_partner_id.display_name, l.project_id.display_name)) if l.employee_id.id in employee_ids: raise ValidationError(_( 'No puede haber empleados repetidos en líneas con el mismo Cliente Final y Proyecto. (Cliente: %s, Proyecto: %s, Empleado: %s)' ) % (l.contract_partner_id.display_name, l.project_id.display_name, l.employee_id.display_name)) employee_ids.add(l.employee_id.id) def action_generate_contract_orders(self): """ Acción disparada por el botón para generar o actualizar pedidos de contrato. """ for template in self: if not template.use_contract_partner or not template.contract_partner_id or \ not template.date_start or not template.date_end: raise UserError(_("Por favor, asegúrese de que 'Usar Partner de Contrato' esté marcado y que el partner, la fecha de inicio y la fecha de fin del contrato estén establecidos.")) try: template._generate_or_update_contract_orders() except Exception as e: raise UserError(_("Ocurrió un error al generar los pedidos: %s") % str(e)) return True # Opcional: puedes devolver una acción de ventana si quieres. def _setup_project_mapping(self, order, project_map): """ Configura el mapeo de empleados y líneas de pedido en los proyectos. Se aplica tanto para proyectos mensuales como originales. """ # Agrupar líneas de plantilla por proyecto project_lines = defaultdict(list) for line in self.sale_order_template_line_ids: if line.project_id: project_lines[line.project_id.id].append(line) for project_id, lines in project_lines.items(): if len(lines) > 1: # Solo si hay más de una línea para el mismo proyecto project = project_map.get(project_id) if not project: continue for line in lines: if not line.employee_id: continue # Solo mapeos con empleado # Buscar la línea de pedido correspondiente so_line = order.order_line.filtered(lambda l: l.template_line_id == line) if so_line: mapping = project.sale_line_employee_ids.filtered(lambda m: m.employee_id == line.employee_id) vals = { 'employee_id': line.employee_id.id, 'sale_line_id': so_line[0].id, 'project_id': project.id, } if mapping: # Si el mapping existe pero no tiene la línea de pedido correcta, actualizar if not mapping.sale_line_id or mapping.sale_line_id != so_line[0]: mapping.write({'sale_line_id': so_line[0].id}) else: project.sale_line_employee_ids = [(0, 0, vals)] def _get_contract_order_dates(self): """ Calcula las fechas para las cuales se deben generar pedidos. Si monthly_invoice_project está activo, genera fechas mensuales. Si no, solo genera una orden con fecha actual. """ self.ensure_one() if not self.date_start or not self.date_end or self.date_start > self.date_end: return [] if not self.monthly_invoice_project: # Solo crear una orden con fecha actual return [fields.Date.today()] # Lógica original para órdenes mensuales order_dates = [] current_date = fields.Date.today() # Determinar la fecha de inicio real para la generación # Debe ser el primer día del mes de self.date_start o el primer día del mes actual, # lo que sea posterior. actual_start_date = self.date_start.replace(day=1) # Asegurarse de que la fecha de inicio real no sea posterior a date_end if actual_start_date > self.date_end: return [] ptr_date = actual_start_date while ptr_date <= self.date_end: order_dates.append(ptr_date) ptr_date += relativedelta(months=1) # Asegurarse de que ptr_date siga siendo el primer día del mes if ptr_date.day != 1: # Esto puede pasar si el mes original tenía más días ptr_date = ptr_date.replace(day=1) return order_dates def _configure_project_for_contract(self, template_line): """ Configura un proyecto para ser billable y asignar el contract_partner_id a la cuenta analítica si existe. """ if template_line.contract_partner_id and template_line.project_id: project_updates = {} # Hacer el proyecto billable if not template_line.project_id.allow_billable: project_updates['allow_billable'] = True # Asignar partner del template al proyecto if template_line.project_id.partner_id != self.contract_partner_id: project_updates['partner_id'] = self.contract_partner_id.id # Actualizar proyecto si hay cambios if project_updates: template_line.project_id.write(project_updates) def _get_or_create_monthly_projects(self, order_datetime, sale_order=None): """ Para cada proyecto único en las líneas del template, crea (o busca) un proyecto mensual para el mes de order_datetime y lo asocia al pedido (sale_order si se provee). Devuelve un mapeo {proyecto_original.id: proyecto_mensual} """ project_map = {} unique_projects = {line.project_id for line in self.sale_order_template_line_ids if line.project_id} for project in unique_projects: # Calcular fechas date_start = order_datetime.date() date_end = (order_datetime + relativedelta(months=1, days=-1)).date() # Sumar las horas de todas las líneas de plantilla que usan este proyecto allocated_hours = sum( line.product_uom_qty for line in self.sale_order_template_line_ids if line.project_id and line.project_id.id == project.id and hasattr(line, 'product_uom_qty') ) # Buscar si ya existe un proyecto mensual para este pedido y mes domain = [ ('name', '=', f"{project.name} - {order_datetime.strftime('%Y-%m')}") ] if sale_order: domain.append(('reinvoiced_sale_order_id', '=', sale_order.id)) project_copy = self.env['project.project'].search(domain, limit=1) vals = { 'name': f"{project.name} - {order_datetime.strftime('%Y-%m')}", 'date_start': date_start, 'date': date_end, 'allocated_hours': allocated_hours, 'sale_line_id': False } # Asignar allow_billable y partner_id si corresponde # Buscar la primera línea de plantilla que use este proyecto y tenga contract_partner_id first_line = next((l for l in self.sale_order_template_line_ids if l.project_id and l.project_id.id == project.id and l.contract_partner_id), None) if first_line: vals['allow_billable'] = True vals['partner_id'] = self.contract_partner_id.id if sale_order: vals['reinvoiced_sale_order_id'] = sale_order.id vals['sale_line_id'] = False if not project_copy: # Copiar el proyecto original project_copy = project.copy(vals) project_copy.write({'sale_line_id': False}) else: # Actualizar el proyecto mensual existente con los valores actuales de la plantilla project_copy.write(vals) project_copy.write({'sale_line_id': False}) project_map[project.id] = project_copy return project_map def _get_project_map(self, order_datetime, sale_order=None): """ Obtiene el mapeo de proyectos según el modo de operación. Si monthly_invoice_project está activo, crea proyectos mensuales. Si no, usa los proyectos originales de la plantilla. """ if self.monthly_invoice_project: return self._get_or_create_monthly_projects(order_datetime, sale_order) else: # Usar los proyectos originales de la plantilla project_map = {} for line in self.sale_order_template_line_ids: if line.project_id: project_map[line.project_id.id] = line.project_id # --- Asignar partner de la cuenta analítica solo una vez por proyecto original --- for project in set(project_map.values()): if project.account_id and project.account_id.partner_id != self.contract_partner_id: project.account_id.write({'partner_id': self.contract_partner_id.id}) return project_map def _process_order_and_projects(self, order, project_map): """ Procesa la orden y sus proyectos: asocia reinvoiced_sale_order_id, configura mapeos de empleados, confirma y factura según corresponda. """ # Asociar proyectos al pedido for project in project_map.values(): project.write({'reinvoiced_sale_order_id': order.id}) # Configurar mapeos de empleados self._setup_project_mapping(order, project_map) # Confirmar y facturar según el modo if self.monthly_invoice_project: self.create_or_update_downpayment_invoice(order) else: # Solo confirmar la orden sin crear factura if order.state in ['draft', 'sent']: original_date = order.date_order order.action_confirm() order.write({'date_order': original_date}) def _generate_or_update_contract_orders(self): self.ensure_one() SaleOrder = self.env['sale.order'] if not self.use_contract_partner or not self.contract_partner_id or not self.date_start or not self.date_end: # No hacer nada si no están las condiciones return order_dates = self._get_contract_order_dates() for order_date in order_dates: # Convertir a datetime a las 12:00 para evitar desfases de zona horaria order_datetime = datetime.combine(order_date, time(12, 0, 0)) # Buscar un pedido existente para este partner, plantilla y mes/año. # Usar sale_order_template_id es clave para identificar unívocamente el pedido # y evitar duplicados, sin importar el estado del pedido. domain = [ ('partner_id', '=', self.contract_partner_id.id), ('sale_order_template_id', '=', self.id), ('date_order', '>=', order_datetime), ('date_order', '<', order_datetime + relativedelta(months=1)), ] existing_order = SaleOrder.search(domain, limit=1) # Obtener mapeo de proyectos según el modo de operación project_map = self._get_project_map(order_datetime, sale_order=existing_order if existing_order else None) if existing_order: # Nunca tocar pedidos finalizados o cancelados por el usuario. if existing_order.state in ['done', 'cancel']: continue # Si hay facturas validadas o pagadas, no podemos proceder automáticamente. # Se registra un aviso para intervención manual. if any(inv.state not in ('draft', 'cancel') for inv in existing_order.invoice_ids): _logger.warning( "Se omite la actualización del pedido %s desde la plantilla %s. " "Tiene facturas validadas que requieren intervención manual.", existing_order.name, self.name ) continue # --- Lógica de actualización de líneas usando el campo template_line_id --- existing_order.invoice_ids.filtered(lambda inv: inv.state == 'draft').unlink() commands = [] template_lines = self.sale_order_template_line_ids.filtered(lambda l: not l.display_type) order_lines_with_link = existing_order.order_line.filtered('template_line_id') template_lines_map = {t_line.id: t_line for t_line in template_lines} order_lines_map = {o_line.template_line_id.id: o_line for o_line in order_lines_with_link} # 1. Actualizar líneas existentes y encontrar nuevas para crear for t_line_id, t_line in template_lines_map.items(): # Configurar proyecto si es necesario self._configure_project_for_contract(t_line) vals = t_line._prepare_order_line_values() # Si monthly_invoice_project está activo y la línea tiene proyecto, asignar el proyecto mensual if self.monthly_invoice_project and t_line.project_id and t_line.project_id.id in project_map: project_month = project_map[t_line.project_id.id] vals['project_id'] = project_month.id # Asignar la cuenta analítica del proyecto mensual a la distribución analítica if project_month.account_id: vals['analytic_distribution'] = {project_month.account_id.id: 100} if t_line_id in order_lines_map: o_line = order_lines_map[t_line_id] # Comparar campos clave para ver si se necesita una actualización. # Si el nombre en la plantilla está vacío, no se considera para la actualización, # permitiendo que el nombre en la línea del pedido (ya sea el default del producto # o uno modificado manualmente) se preserve. price_unit_in_template = vals.get('price_unit') name_in_template = vals.get('name') qty_in_template = vals.get('product_uom_qty') # Determinar el precio unitario objetivo. # Si la plantilla especifica un precio (distinto de cero), se usa ese. # Si no, se recalcula para obtener el precio por defecto (según lista de precios). target_price_unit = 0.0 if price_unit_in_template: target_price_unit = price_unit_in_template else: # Recalcular para obtener el precio por defecto. new_sol = self.env['sale.order.line'].new({ 'order_id': existing_order.id, 'product_id': o_line.product_id.id, 'product_uom': o_line.product_uom.id, 'product_uom_qty': qty_in_template, 'order_partner_id': existing_order.partner_id.id, }) new_sol._compute_price_unit() target_price_unit = new_sol.price_unit # Verificar si hay cambios que requieran actualización needs_update = ( o_line.product_uom_qty != qty_in_template or (name_in_template and o_line.name != name_in_template) or o_line.price_unit != target_price_unit or o_line.project_id != t_line.project_id ) # Verificar cambios en distribución analítica analytic_distribution_in_template = vals.get('analytic_distribution', {}) if o_line.analytic_distribution != analytic_distribution_in_template: needs_update = True if needs_update: update_payload = { 'product_uom_qty': qty_in_template, 'price_unit': target_price_unit, 'analytic_distribution': analytic_distribution_in_template, } if name_in_template: update_payload['name'] = name_in_template # Si monthly_invoice_project está activo y la línea tiene proyecto, asignar el proyecto mensual if self.monthly_invoice_project and t_line.project_id and t_line.project_id.id in project_map: project_month = project_map[t_line.project_id.id] update_payload['project_id'] = project_month.id # Asignar la cuenta analítica del proyecto mensual a la distribución analítica if project_month.account_id: update_payload['analytic_distribution'] = {project_month.account_id.id: 100} elif o_line.project_id != t_line.project_id: update_payload['project_id'] = t_line.project_id.id if t_line.project_id else False commands.append(Command.update(o_line.id, update_payload)) del order_lines_map[t_line_id] # Marcar como procesada else: # Línea nueva en la plantilla, crearla en el pedido commands.append(Command.create(vals)) # 2. Poner en cero las líneas que ya no están en la plantilla for o_line in order_lines_map.values(): commands.append(Command.update(o_line.id, {'product_uom_qty': 0})) # 3. Poner en cero las líneas de anticipo existentes para que se recreen for dp_line in existing_order.order_line.filtered('is_downpayment'): commands.append(Command.update(dp_line.id, {'product_uom_qty': 0, 'price_unit': 0})) # 4. Escribir todos los cambios de líneas en una sola operación if commands: existing_order.write({'order_line': commands}) # 5. Actualizar campos del pedido y la fecha update_vals = {'date_order': order_datetime} if self.payment_term_id and existing_order.payment_term_id != self.payment_term_id: update_vals['payment_term_id'] = self.payment_term_id.id existing_order.write(update_vals) # Procesar orden y proyectos self._process_order_and_projects(existing_order, project_map) else: # Crear nuevo pedido pricelist = self.contract_partner_id.property_product_pricelist # Priorizar término de pago de la plantilla, si no, el del partner payment_term = self.payment_term_id or self.contract_partner_id.property_payment_term_id # Configurar proyectos antes de crear líneas for line in self.sale_order_template_line_ids: self._configure_project_for_contract(line) # Obtener mapeo de proyectos project_map = self._get_project_map(order_datetime) order_lines_vals = [] for line in self.sale_order_template_line_ids: vals = line._prepare_order_line_values() if self.monthly_invoice_project and line.project_id and line.project_id.id in project_map: project_month = project_map[line.project_id.id] vals['project_id'] = project_month.id # Asignar la cuenta analítica del proyecto mensual a la distribución analítica if project_month.account_id: vals['analytic_distribution'] = {project_month.account_id.id: 100} order_lines_vals.append(vals) order_vals = { 'partner_id': self.contract_partner_id.id, 'sale_order_template_id': self.id, 'date_order': order_datetime, 'pricelist_id': pricelist.id if pricelist else False, 'payment_term_id': payment_term.id if payment_term else False, 'company_id': (self.company_id or self.env.company).id, 'order_line': [Command.create(vals) for vals in order_lines_vals] } new_order = SaleOrder.create(order_vals) # Procesar orden y proyectos self._process_order_and_projects(new_order, project_map) def create_or_update_downpayment_invoice(self, order): """ Borra facturas y líneas de adelanto draft, crea un adelanto del 100% con la fecha igual a la orden y confirma la orden y la factura. """ # 1. Eliminar facturas draft relacionadas a la orden draft_invoices = order.invoice_ids.filtered(lambda inv: inv.state == 'draft') draft_invoices.unlink() # 2. Las líneas de adelanto antiguas ya se han puesto a cero en la lógica de actualización. # No se deben borrar (`unlink`) de un pedido confirmado. # 3. Confirmar la orden si está en borrador if order.state in ['draft', 'sent']: original_date = order.date_order order.action_confirm() # Esto cambiará la fecha de la orden al momento actual order.write({'date_order': original_date}) # Restaurar la fecha original # 4. Crear adelanto del 100% wizard = self.env['sale.advance.payment.inv'].create({ 'advance_payment_method': 'percentage', 'amount': 100, 'sale_order_ids': [(6, 0, [order.id])], }) invoices = wizard._create_invoices(order) # 5. Forzar la fecha de la factura igual a la orden if invoices: invoice_date = order.date_order.date() invoices.write({'invoice_date': invoice_date, 'date': invoice_date}) # 6. Confirmar la factura # if invoices: # invoices.action_post()