|
|
@@ -0,0 +1,256 @@
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+from odoo import models, fields, api, _
|
|
|
+from odoo.exceptions import UserError
|
|
|
+from dateutil.relativedelta import relativedelta
|
|
|
+from datetime import datetime, time
|
|
|
+from odoo import Command # Import Command para operaciones de One2many
|
|
|
+import logging
|
|
|
+
|
|
|
+_logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+class SaleOrderTemplate(models.Model):
|
|
|
+ _inherit = 'sale.order.template'
|
|
|
+
|
|
|
+ use_contract_partner = fields.Boolean(
|
|
|
+ string='Usar Partner de Contrato',
|
|
|
+ help="Marque esta casilla para asociar un Partner específico a esta plantilla y sus líneas."
|
|
|
+ )
|
|
|
+
|
|
|
+ contract_partner_id = fields.Many2one(
|
|
|
+ 'res.partner',
|
|
|
+ string='Partner del Contrato',
|
|
|
+ help="Partner opcional asociado a esta plantilla de presupuesto."
|
|
|
+ )
|
|
|
+
|
|
|
+ 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."
|
|
|
+ )
|
|
|
+
|
|
|
+ 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 _get_contract_order_dates(self):
|
|
|
+ """
|
|
|
+ Calcula las fechas (primer día de cada mes) para las cuales se deben generar pedidos.
|
|
|
+ """
|
|
|
+ self.ensure_one()
|
|
|
+ if not self.date_start or not self.date_end or self.date_start > self.date_end:
|
|
|
+ return []
|
|
|
+
|
|
|
+ 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 _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)
|
|
|
+
|
|
|
+ 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():
|
|
|
+ vals = t_line._prepare_order_line_values()
|
|
|
+ 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
|
|
|
+
|
|
|
+ # También actualizar si cambia el project_id
|
|
|
+ if (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):
|
|
|
+
|
|
|
+ update_payload = {
|
|
|
+ 'product_uom_qty': qty_in_template,
|
|
|
+ 'price_unit': target_price_unit,
|
|
|
+ }
|
|
|
+ if name_in_template:
|
|
|
+ update_payload['name'] = name_in_template
|
|
|
+ if 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})
|
|
|
+ # Forzar recálculo del campo 'code' en las líneas de pedido actualizadas
|
|
|
+ existing_order.order_line._fields['code'].recompute(existing_order.order_line)
|
|
|
+
|
|
|
+ # 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)
|
|
|
+
|
|
|
+ self.create_or_update_downpayment_invoice(existing_order)
|
|
|
+ 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
|
|
|
+ order_lines_vals = [line._prepare_order_line_values() for line in self.sale_order_template_line_ids]
|
|
|
+
|
|
|
+ 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)
|
|
|
+ self.create_or_update_downpayment_invoice(new_order)
|
|
|
+
|
|
|
+ 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()
|