| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- # -*- 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."
- )
- has_contract_orders = fields.Boolean(
- string='Tiene órdenes de contrato',
- compute='_compute_has_contract_orders',
- store=False
- )
- def _compute_has_contract_orders(self):
- SaleOrder = self.env['sale.order']
- for rec in self:
- count = SaleOrder.search_count([('sale_order_template_id', '=', rec.id)])
- rec.has_contract_orders = count > 0
- @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 la fecha de inicio del contrato.
- """
- 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 la fecha de inicio del contrato
- return [self.date_start]
- # Lógica original para órdenes mensuales
- order_dates = []
- current_date = fields.Date.today()
- actual_start_date = self.date_start.replace(day=1)
- 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)
- if ptr_date.day != 1:
- 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, crea una copia del proyecto original para el pedido (con el mismo nombre y fechas del contrato).
- """
- if self.monthly_invoice_project:
- return self._get_or_create_monthly_projects(order_datetime, sale_order)
- else:
- # Crear una copia del proyecto original para el pedido (con el mismo nombre y fechas del contrato)
- 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:
- # Buscar si ya existe una copia para este pedido, nombre y fechas
- domain = [
- ('name', '=', project.name),
- ('date_start', '=', self.date_start),
- ('date', '=', self.date_end),
- ]
- if sale_order:
- domain.append(('reinvoiced_sale_order_id', '=', sale_order.id))
- project_copy = self.env['project.project'].search(domain, limit=1)
- vals = {
- 'name': project.name,
- 'date_start': self.date_start,
- 'date': self.date_end,
- 'sale_line_id': False
- }
- # Asignar allow_billable y partner_id si corresponde
- 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:
- project_copy = project.copy(vals)
- project_copy.write({'sale_line_id': False})
- else:
- project_copy.write(vals)
- project_copy.write({'sale_line_id': False})
- project_map[project.id] = project_copy
- 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()
|