Ver código fonte

parametros para registro de horas en proyectos y ordenes de venta

Roberto pineda 7 meses atrás
pai
commit
1d5712fc44

+ 4 - 0
sale_template_contract/__init__.py

@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models

+ 24 - 0
sale_template_contract/__manifest__.py

@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+{
+    'name': 'Sale Template Contract Partner',
+    'version': '18.0.1.0.0',
+    'category': 'Sales/Sales',
+    'summary': 'Extiende las plantillas de presupuesto para asociar un partner.',
+    'description': """
+        Este módulo añade un campo opcional para vincular un Partner (cliente/proveedor)
+        tanto a la cabecera de la plantilla de presupuesto como a sus líneas.
+    """,
+    'author': 'Gemini Code Assist',
+    'website': '',
+    'depends': [
+        'sale_management',  # Dependencia base para plantillas de presupuesto
+    ],
+    'data': [
+        'security/ir.model.access.csv',
+        'views/sale_order_template_views.xml',
+    ],
+    'installable': True,
+    'application': False,
+    'auto_install': False,
+    'license': 'LGPL-3',
+}

+ 4 - 0
sale_template_contract/models/__init__.py

@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+from . import sale_order_template
+from . import sale_order_template_line
+from . import sale_order_line

+ 32 - 0
sale_template_contract/models/sale_order_line.py

@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api
+
+class SaleOrderLine(models.Model):
+    _inherit = 'sale.order.line'
+
+    template_line_id = fields.Many2one(
+        'sale.order.template.line',
+        string='Línea de Plantilla Origen',
+        help="Línea de la plantilla de presupuesto que originó esta línea de pedido.",
+        index=True,
+        copy=False, # No copiar al duplicar el pedido
+        ondelete='set null' # No eliminar la línea si se borra la de la plantilla
+    )
+
+    code = fields.Char(
+        string='Código de Línea',
+        compute='_compute_code',
+        store=True, # Almacenar el valor en la base de datos
+        help="Código único para la línea de pedido, basado en su origen (plantilla o línea de venta directa)."
+    )
+
+    @api.depends('template_line_id', 'product_id', 'product_id.type', 'template_line_id.project_id')
+    def _compute_code(self):
+        for line in self:
+            if line.product_id.type == 'service':
+                if line.template_line_id and line.template_line_id.project_id:
+                    line.code = f"P-{line.product_id.id}"
+                else:
+                    line.code = f"SOL-{line.id}"
+            else:
+                line.code = False # O None, para que el campo quede vacío

+ 256 - 0
sale_template_contract/models/sale_order_template.py

@@ -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()

+ 51 - 0
sale_template_contract/models/sale_order_template_line.py

@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields
+
+class SaleOrderTemplateLine(models.Model):
+    _inherit = 'sale.order.template.line'
+
+    contract_partner_id = fields.Many2one(
+        'res.partner',
+        string='Partner del Contrato (Línea)',
+        help="Partner opcional asociado a esta línea de la plantilla de presupuesto."
+    )
+
+    employee_id = fields.Many2one(
+        'hr.employee',
+        string='Empleado',
+        help="Empleado opcional asociado a esta línea de la plantilla de presupuesto."
+    )
+
+    price_unit = fields.Float(
+        string='Precio Unitario (Plantilla)',
+        help="Precio unitario opcional para esta línea de la plantilla. Si se deja en cero, se usará el precio del producto."
+    )
+
+    project_id = fields.Many2one(
+        'project.project',
+        string='Proyecto',
+        help="Proyecto opcional asociado a esta línea de la plantilla de presupuesto."
+    )
+
+    def _prepare_order_line_values(self):
+        vals = super()._prepare_order_line_values()
+        vals['template_line_id'] = self.id
+        if self.price_unit:
+            vals['price_unit'] = self.price_unit
+        # Modificar descripción si hay partner y/o proyecto
+        partner_name = self.contract_partner_id.name if self.contract_partner_id else ''
+        project_name = self.project_id.name if self.project_id else ''
+        extra_desc = ''
+        if partner_name and project_name:
+            extra_desc = f"{partner_name} - {project_name}"
+        elif partner_name:
+            extra_desc = partner_name
+        elif project_name:
+            extra_desc = project_name
+        if extra_desc:
+            # Si ya hay descripción, agregar en una nueva línea
+            if vals.get('name'):
+                vals['name'] = f"{vals['name']}\n{extra_desc}"
+            else:
+                vals['name'] = extra_desc
+        return vals

+ 5 - 0
sale_template_contract/security/ir.model.access.csv

@@ -0,0 +1,5 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_sale_order_template_contract_partner,sale.order.template.contract.partner,model_sale_order_template,base.group_user,1,1,1,1
+access_sale_order_template_line_contract_partner,sale.order.template.line.contract.partner,model_sale_order_template_line,base.group_user,1,1,1,1
+access_sale_order_template_date_start,sale.order.template.date_start,model_sale_order_template,base.group_user,1,1,1,1
+access_sale_order_template_date_end,sale.order.template.date_end,model_sale_order_template,base.group_user,1,1,1,1

+ 51 - 0
sale_template_contract/views/sale_order_template_views.xml

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+
+        <record id="sale_order_template_view_form_inherit_contract" model="ir.ui.view">
+            <field name="name">sale.order.template.form.inherit.contract</field>
+            <field name="model">sale.order.template</field>
+            <field name="inherit_id" ref="sale_management.sale_order_template_view_form"/>
+            <field name="arch" type="xml">
+                <xpath expr="//div[@name='button_box']" position="inside">
+                    <button name="action_generate_contract_orders"
+                            type="object"
+                            string="Generar/Actualizar Pedidos de Contrato"
+                            class="oe_highlight"
+                            invisible="not use_contract_partner or not contract_partner_id or not date_start or not date_end"/>
+                </xpath>
+
+                <xpath expr="//group[@name='so_confirmation']" position="inside">
+                    <field name="use_contract_partner"/>
+                    <field name="contract_partner_id"
+                           options="{'no_create': True, 'no_open': False}"
+                           invisible="not use_contract_partner"/>
+                    <field name="payment_term_id" invisible="not use_contract_partner"/>
+                    <field name="date_start" invisible="not use_contract_partner"/>
+                    <field name="date_end" invisible="not use_contract_partner"/>
+                </xpath>
+
+                <xpath expr="//field[@name='sale_order_template_line_ids']/form/group/group/field[@name='product_id']" position="after">
+                    <field name="contract_partner_id" optional="show" options="{'no_create': True, 'no_open': False}"
+                           column_invisible="not parent.use_contract_partner"/>
+                    <field name="employee_id" optional="show" options="{'no_create': True, 'no_open': False}"
+                           column_invisible="not parent.use_contract_partner"/>
+                    <field name="price_unit" optional="show" column_invisible="not parent.use_contract_partner"/>
+                    <field name="project_id" optional="show" options="{'no_create': False, 'no_open': False}"
+                           column_invisible="not parent.use_contract_partner"/>
+                </xpath>
+                
+                <xpath expr="//field[@name='sale_order_template_line_ids']/list/field[@name='product_id']" position="after">
+                    <field name="contract_partner_id" optional="show" options="{'no_create': True, 'no_open': False}"
+                           column_invisible="not parent.use_contract_partner"/>
+                    <field name="employee_id" optional="show" options="{'no_create': True, 'no_open': False}"
+                           column_invisible="not parent.use_contract_partner"/>
+                    <field name="price_unit" optional="show" column_invisible="not parent.use_contract_partner"/>
+                    <field name="project_id" optional="show" options="{'no_create': False, 'no_open': False}"
+                           column_invisible="not parent.use_contract_partner"/>
+                </xpath>
+            </field>
+        </record>
+
+    </data>
+</odoo>